diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e04c9183bca5..765936f51a1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1655,6 +1655,9 @@ jobs: exit "$test_status" fi + - name: Test Codex writer restore execution boundary + run: python3 tests/test_codex_writer_restore.py + - name: Run Swift package unit tests run: | set -euo pipefail diff --git a/CLI/CMUXCLI+CodexWriterRestore.swift b/CLI/CMUXCLI+CodexWriterRestore.swift new file mode 100644 index 000000000000..7a630d0cdaec --- /dev/null +++ b/CLI/CMUXCLI+CodexWriterRestore.swift @@ -0,0 +1,64 @@ +import CMUXAgentLaunch +import Foundation + +extension CMUXCLI { + /// Runs before the binding claim, and repeats cheaply at the exec boundary. + /// Uses the exact child environment and actual cwd, never verification-only + /// metadata or the socket's relay status (a relay can still run local Codex). + func guardCodexWriterBeforeRestore( + sessionID: String?, + arguments: [String], + environment: [String: String], + includeOwnerDetails: Bool = true + ) throws { + guard let sessionID else { return } + let preflight = includeOwnerDetails ? CodexWriterRestorePreflight() : CodexWriterRestorePreflight { _ in + CodexWriterOwnerScan(owners: [], isComplete: false) + } + let inspection = preflight.inspect( + sessionID: sessionID, + arguments: arguments, + environment: environment, + workingDirectory: FileManager.default.currentDirectoryPath, + fallbackHome: NSHomeDirectory() + ) + guard !inspection.permitsLaunch else { return } + throw loggedRestoreError( + stage: inspection.lock?.state == .active ? "session.active-writer" : "session.writer-check-unavailable", + detail: "session=\(sessionID)", + message: CodexWriterRestoreMessage(inspection: inspection).text + ) + } + + /// A login-shell command may override its parent's home. Only literal + /// Codex commands carrying their own absolute CODEX_HOME can be preflighted + /// without changing the captured command or evaluating arbitrary shell code. + func guardLegacyCodexWriter( + command: String, + record: RestoreRecord, + environment: [String: String], + includeOwnerDetails: Bool = true + ) throws { + let normalizedMode = record.mode.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedKind = record.kind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard normalizedMode == AgentRestoreRequestMode.resumeAgent.rawValue, + normalizedKind == "codex" else { return } + guard let sessionID = record.checkpointID, + let legacy = CodexLegacyRestoreCommand(command: command, sessionID: sessionID) else { + throw loggedRestoreError( + stage: "session.legacy-writer-scope", + detail: "legacy Codex home is not explicit", + message: String( + localized: "codex.restore.legacyScopeUnavailable", + defaultValue: "cmux cannot safely check ownership for this older shell-only Codex restore. No writer was started. Continue in the original terminal, or exit that session normally before retrying." + ) + ) + } + try guardCodexWriterBeforeRestore( + sessionID: sessionID, + arguments: legacy.arguments, + environment: environment.merging(legacy.environment) { _, saved in saved }, + includeOwnerDetails: includeOwnerDetails + ) + } +} diff --git a/CLI/CMUXCLI+Restore.swift b/CLI/CMUXCLI+Restore.swift index 37ec0d5cfe1e..244c3d93911e 100644 --- a/CLI/CMUXCLI+Restore.swift +++ b/CLI/CMUXCLI+Restore.swift @@ -10,33 +10,6 @@ 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 verificationHome = command.verificationHome { - payload["verification_home"] = verificationHome - } - if let capturedAt = command.capturedAt { - payload["captured_at"] = capturedAt - } - if let source = command.source { - payload["source"] = source - } - return payload - } func runRestoreCommand( commandArgs: [String], @@ -124,6 +97,10 @@ extension CMUXCLI { } } + let environment = processEnvironment.merging(record.environment) { _, restored in restored } + if record.launchCommand == nil, record.preparedArguments == nil, let command = record.legacyCommand { + try guardLegacyCodexWriter(command: command, record: record, environment: environment) + } // Legacy command-only records predate structured launch captures, but // an agent-hook Codex record still names a mutable surface owner. Claim // that generation before handing the shell command to exec so an @@ -151,9 +128,6 @@ extension CMUXCLI { return } - let environment = processEnvironment.merging(record.environment) { _, restored in - restored - } if record.launchCommand == nil, record.preparedArguments == nil, let legacyCommand = record.legacyCommand { @@ -206,6 +180,7 @@ extension CMUXCLI { ambientEnvironment: processEnvironment ) else { if let legacyCommand = record.legacyCommand { + try guardLegacyCodexWriter(command: legacyCommand, record: record, environment: environment) if codexRestoreBindingRequiresClaim(record), !claimCodexRestoreBinding( record: record, @@ -242,6 +217,9 @@ extension CMUXCLI { ) } + try guardCodexWriterBeforeRestore( + sessionID: invocation.codexResumeSessionID, arguments: invocation.arguments, environment: invocation.environment + ) for preflight in invocation.preflightInvocations { try runRestorePreflight( preflight, diff --git a/CLI/CMUXCLI+RestoreExecution.swift b/CLI/CMUXCLI+RestoreExecution.swift index 9527e3cf66e6..966b7723b713 100644 --- a/CLI/CMUXCLI+RestoreExecution.swift +++ b/CLI/CMUXCLI+RestoreExecution.swift @@ -48,6 +48,12 @@ extension CMUXCLI { if let appliedWorkingDirectory { invocationEnvironment["PWD"] = appliedWorkingDirectory } + try guardCodexWriterBeforeRestore( + sessionID: invocation.codexResumeSessionID, + arguments: invocation.arguments, + environment: invocationEnvironment, + includeOwnerDetails: false + ) guard let first = invocation.arguments.first, let executable = resolveRestoreExecutable( first, @@ -94,6 +100,7 @@ extension CMUXCLI { if let appliedWorkingDirectory { legacyEnvironment["PWD"] = appliedWorkingDirectory } + try guardLegacyCodexWriter(command: command, record: record, environment: legacyEnvironment, includeOwnerDetails: false) client.close() try execLegacyRestoreCommand(command, environment: legacyEnvironment) } diff --git a/CLI/CMUXCLI+RestoreLaunchPayload.swift b/CLI/CMUXCLI+RestoreLaunchPayload.swift new file mode 100644 index 000000000000..8e3ad0493c3d --- /dev/null +++ b/CLI/CMUXCLI+RestoreLaunchPayload.swift @@ -0,0 +1,32 @@ +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 verificationHome = command.verificationHome { + payload["verification_home"] = verificationHome + } + if let capturedAt = command.capturedAt { + payload["captured_at"] = capturedAt + } + if let source = command.source { + payload["source"] = source + } + return payload + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/README.md b/Packages/macOS/CMUXAgentLaunch/README.md index 04a60b0e82f0..17c4f7264cc7 100644 --- a/Packages/macOS/CMUXAgentLaunch/README.md +++ b/Packages/macOS/CMUXAgentLaunch/README.md @@ -55,3 +55,30 @@ let results = CodexSessionResumeVerifier().verifyBatch( fileManager: fixtureFileManager ) ``` + +## Testing Codex writer ownership + +`CodexWriterRestorePreflight` consumes final argv, environment, and actual cwd. +Tests create temporary homes and hold their own nonblocking `flock`; they never +use a real conversation or remove a provider-owned lock. Inject `ownerLookup` +to model a release during discovery, and use `mappedSurface(in:)` to check +ambiguous runtime candidates without AppKit. Production continuation requires +an active lock, a complete single-holder scan, current process ancestry and +kernel TTY, then a second process/runtime-generation check before focus. + +```swift +let result = CodexWriterRestorePreflight().inspect( + sessionID: fixtureThreadID, + arguments: ["codex", "resume", fixtureThreadID], + environment: ["CODEX_HOME": fixtureHome.path], + workingDirectory: fixtureProject.path, + fallbackHome: fixtureUserHome.path +) +``` + +The probe releases its own descriptor before launch. Codex remains the final +atomic lock authority for later races. A local actor cannot replace this +cross-process kernel check. CLI execution stays synchronous for `execve`; +Vault awaits bounded inspection off the main actor. Unknown owners, remote +providers, and uninspectable legacy shell commands never trigger guessed focus, +lock deletion, process termination, or an implicit fork. diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreInvocation.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreInvocation.swift index bb78abc7e669..c3185df6a1b3 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreInvocation.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreInvocation.swift @@ -8,17 +8,21 @@ public struct AgentRestoreInvocation: Equatable, Sendable { public let environment: [String: String] /// Typed subprocesses that must succeed before the final process replacement. public let preflightInvocations: [AgentRestorePreflightInvocation] + /// Validated Codex thread requiring an ownership check at the exec boundary. + public let codexResumeSessionID: String? /// Creates a planned restore or fork invocation. public init( arguments: [String], workingDirectory: String?, environment: [String: String], - preflightInvocations: [AgentRestorePreflightInvocation] = [] + preflightInvocations: [AgentRestorePreflightInvocation] = [], + codexResumeSessionID: String? = nil ) { self.arguments = arguments self.workingDirectory = workingDirectory self.environment = environment self.preflightInvocations = preflightInvocations + self.codexResumeSessionID = codexResumeSessionID } } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift index fb5de676c94f..2260dc706dd5 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift @@ -103,11 +103,13 @@ public struct AgentRestorePlanner: Sendable { ambientEnvironment: ambientEnvironment, profilePin: hermesProfilePin ) + let normalizedCheckpointID = normalized(request.checkpointID) return AgentRestoreInvocation( arguments: routedArguments, workingDirectory: workingDirectory, environment: environment, - preflightInvocations: preflights + preflightInvocations: preflights, + codexResumeSessionID: kind == "codex" && request.mode == .resumeAgent ? normalizedCheckpointID : nil ) } @@ -191,6 +193,12 @@ public struct AgentRestorePlanner: Sendable { ) -> [String: String] { var captured = request.launchCommand?.environment ?? [:] captured.merge(request.environment) { _, binding in binding } + if kind == "codex", request.mode == .resumeAgent, normalized(captured["CODEX_HOME"]) == nil, + let home = normalized(request.launchCommand?.verificationHome) ?? normalized(captured["HOME"]) { + // Verification and execution must select the same account, even + // when restore runs from a different login shell after relaunch. + captured["CODEX_HOME"] = CodexHomeResolver().resolve(launchVerificationHome: home) + } if kind == "codex", let rawCodexHome = normalized(captured["CODEX_HOME"]), let launchWorkingDirectory = normalized(request.launchCommand?.workingDirectory) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexLegacyRestoreCommand.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexLegacyRestoreCommand.swift new file mode 100644 index 000000000000..03e3e051dfe3 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexLegacyRestoreCommand.swift @@ -0,0 +1,91 @@ +import Foundation + +/// The inspectable subset of old, literal shell-only Codex resume commands. +/// +/// Parsing never executes shell code or rewrites the original command. An +/// absolute inline CODEX_HOME is required because login-shell startup can +/// override inherited environment. Complex commands deliberately fail closed. +public struct CodexLegacyRestoreCommand: Sendable { + /// Literal argv, including the Codex executable. + public let arguments: [String] + /// Inline assignments that determine the actual account. + public let environment: [String: String] + + /// Recognizes a literal resume bound to the expected session and account. + /// - Parameters: + /// - command: Original shell command, not evaluated by this parser. + /// - sessionID: Validated checkpoint UUID; a different command binding is rejected. + public init?(command: String, sessionID: String) { + guard let words = Self.literalWords(command), UUID(uuidString: sessionID) != nil else { return nil } + var index = 0 + var environment: [String: String] = [:] + if words.first == "exec" { index += 1 } + if index < words.count, ["env", "/usr/bin/env"].contains(words[index]) { index += 1 } + while index < words.count, let equals = words[index].firstIndex(of: "=") { + let name = String(words[index][.. [String]? { + guard command.utf8.count <= 65_536 else { return nil } + var words: [String] = [] + var word = "" + var quote: Character? + var escaped = false + var started = false + for character in command { + guard !character.isNewline, character != "\0" else { return nil } + if escaped { + word.append(character) + escaped = false + } else if quote == "'" { + if character == "'" { quote = nil } else { word.append(character) } + } else if character == "\\" { + // Double-quoted backslash rules differ from unquoted ones. + guard quote == nil else { return nil } + escaped = true + started = true + } else if character == "$" || character == "`" { + return nil + } else if let currentQuote = quote { + if character == currentQuote { quote = nil } else { word.append(character) } + } else if character == "'" || character == "\"" { + quote = character + started = true + } else if character.isWhitespace { + if started { words.append(word); word = ""; started = false } + } else if ";&|<>(){}[]*?!~#".contains(character) { + return nil + } else { + word.append(character) + started = true + } + } + guard quote == nil, !escaped else { return nil } + if started { words.append(word) } + return words + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockInspection.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockInspection.swift new file mode 100644 index 000000000000..1077da3dcf94 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockInspection.swift @@ -0,0 +1,23 @@ +import Foundation + +/// A point-in-time probe of Codex's writer lock, never a reservation to launch. +public struct CodexWriterLockInspection: Equatable, Sendable { + /// Whether the exact file is free, actively locked, or cannot be inspected. + public enum State: Equatable, Sendable { + /// No writer held the lock at the instant of the probe. + case available + /// Another descriptor held an incompatible kernel lock. + case active + /// The lock could not be inspected safely; do not start a writer. + case unavailable + } + + /// The result of a nonblocking kernel lock probe. + public let state: State + /// The home/account whose lock was inspected. + public let codexHome: String + /// The exact lock filename, suitable for a read-only diagnostic command. + public let lockPath: String + let device: Int32? + let inode: UInt64? +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockInspector.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockInspector.swift new file mode 100644 index 000000000000..1a55780a93a8 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterLockInspector.swift @@ -0,0 +1,57 @@ +import Darwin +import Foundation + +/// Inspects Codex's cross-process writer lock without creating or removing files. +/// +/// The probe releases its descriptor before returning. Codex remains the final +/// authority if another writer starts between this preflight and TUI startup. +public struct CodexWriterLockInspector: Sendable { + /// Creates a stateless kernel lock inspector. + public init() {} + + /// Probes an exact UUID thread under the supplied effective Codex home. + /// + /// - Parameters: + /// - sessionID: The Codex thread UUID, normalized to Codex's filename spelling. + /// - codexHome: The effective state directory of the process being launched. + /// - Returns: Lock availability, or unavailable for malformed/unreadable state. + public func inspect(sessionID: String, codexHome: String) -> CodexWriterLockInspection { + let threadID = UUID(uuidString: sessionID)?.uuidString.lowercased() + let home: String + if !codexHome.utf8.contains(0), let resolved = realpath(codexHome, nil) { + home = String(cString: resolved) + free(resolved) + } else { + home = codexHome + } + let path = home + "/thread-writer-locks/" + (threadID ?? "invalid-thread") + ".lock" + func result(_ state: CodexWriterLockInspection.State, _ file: stat? = nil) -> CodexWriterLockInspection { + CodexWriterLockInspection( + state: state, codexHome: home, lockPath: path, + device: file?.st_dev, inode: file?.st_ino + ) + } + guard threadID != nil, codexHome.hasPrefix("/"), !codexHome.utf8.contains(0) else { + return result(.unavailable) + } + // A cross-process flock is required to observe Codex's own lock; an + // actor or file-existence check cannot establish this kernel invariant. + let fd = open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW) + guard fd >= 0 else { return result(errno == ENOENT ? .available : .unavailable) } + defer { close(fd) } + var file = stat() + guard fstat(fd, &file) == 0, file.st_mode & S_IFMT == S_IFREG else { + return result(.unavailable) + } + let status = flock(fd, LOCK_EX | LOCK_NB) + let error = errno + // Closing our own descriptor releases only the probe's acquisition. + var current = stat() + guard lstat(path, ¤t) == 0, + current.st_dev == file.st_dev, current.st_ino == file.st_ino else { + return result(.unavailable) + } + if status == 0 { return result(.available, file) } + return result(error == EWOULDBLOCK ? .active : .unavailable, file) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwner.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwner.swift new file mode 100644 index 000000000000..1bc7a71295b2 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwner.swift @@ -0,0 +1,20 @@ +import Foundation + +/// A live process with an open descriptor for the inspected lock inode. +/// +/// An open descriptor alone does not prove ownership. Callers also require an +/// active lock and exactly one holder before offering a continuation target. +public struct CodexWriterOwner: Equatable, Sendable { + /// Kernel PID identifying the holder. + public let pid: Int32 + let startSeconds: UInt64 + let startMicroseconds: UInt64 + /// Executable path for diagnostics, never used to find a session. + public let executable: String + /// Current kernel cwd, when readable. + public let workingDirectory: String? + /// Live kernel controlling-terminal device, independent of reported tty names. + public let ttyDevice: Int64? + /// Current ancestors up to the terminal foreground process. + public let ancestorPIDs: Set +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwnerScan.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwnerScan.swift new file mode 100644 index 000000000000..1948c1f16634 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterOwnerScan.swift @@ -0,0 +1,16 @@ +/// Descriptor holders discovered during a bounded process scan. +public struct CodexWriterOwnerScan: Sendable { + /// Verified live holders, also useful for diagnostics when the scan was incomplete. + public let owners: [CodexWriterOwner] + /// Whether every same-user candidate could be inspected within the deadline. + public let isComplete: Bool + + /// Creates descriptor evidence from an injected discovery implementation. + /// - Parameters: + /// - owners: Verified process generations opening the inspected inode. + /// - isComplete: False when inspection was skipped, denied, or timed out. + public init(owners: [CodexWriterOwner], isComplete: Bool) { + self.owners = owners + self.isComplete = isComplete + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterProcessInspector.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterProcessInspector.swift new file mode 100644 index 000000000000..a9b69d24b0fb --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterProcessInspector.swift @@ -0,0 +1,129 @@ +import Darwin +import Foundation + +/// Reads live descriptor and process identity evidence for a locked Codex thread. +/// Keep this bounded I/O off the UI actor; it is also used by the synchronous CLI. +public struct CodexWriterProcessInspector: Sendable { + /// Creates a stateless process inspector. + public init() {} + + /// Finds current holders of the exact lock inode without reading process environments. + /// + /// - Parameter inspection: An active lock probe with a verified device/inode. + /// - Returns: Holder generations plus completeness; an incomplete scan cannot authorize focus. + public func owners(for inspection: CodexWriterLockInspection) -> CodexWriterOwnerScan { + guard inspection.state == .active else { return CodexWriterOwnerScan(owners: [], isComplete: true) } + let deadline = ProcessInfo.processInfo.systemUptime + 2 + var pids = [Int32](repeating: 0, count: 8192) + let bytes = pids.withUnsafeMutableBytes { proc_listpids(UInt32(PROC_UID_ONLY), getuid(), $0.baseAddress, Int32($0.count)) } + let count = Int(bytes) / MemoryLayout.stride + guard count > 0, count < pids.count else { return CodexWriterOwnerScan(owners: [], isComplete: false) } + var owners: [CodexWriterOwner] = [] + var complete = true + for pid in pids.prefix(Int(count)) where pid > 0 { + guard !Task.isCancelled, ProcessInfo.processInfo.systemUptime < deadline else { + return CodexWriterOwnerScan(owners: owners, isComplete: false) + } + guard let info = process(pid) else { + if errno == ESRCH { continue } + complete = false + continue + } + guard info.pbi_uid == getuid() else { continue } + guard let holdsLock = holds(inspection, pid: pid) else { complete = false; continue } + guard holdsLock else { continue } + guard let owner = owner(pid: pid, info: info), isCurrent(owner, inspection: inspection) else { + complete = false + continue + } + owners.append(owner) + } + return CodexWriterOwnerScan(owners: owners, isComplete: complete) + } + + /// Revalidates a holder's generation and exact descriptor before navigation. + /// + /// - Parameters: + /// - owner: Previously observed holder. + /// - inspection: Lock inode from the corresponding active probe. + /// - Returns: Whether the same live generation still opens that inode. + public func isCurrent(_ owner: CodexWriterOwner, inspection: CodexWriterLockInspection) -> Bool { + guard let info = process(owner.pid), info.pbi_start_tvsec == owner.startSeconds, + info.pbi_start_tvusec == owner.startMicroseconds, + owner.ttyDevice == ttyDevice(info) else { return false } + return holds(inspection, pid: owner.pid) == true + } + + /// Checks that a current foreground runtime is in the holder's live ancestry. + /// - Parameters: + /// - owner: The holder whose ancestry must still agree. + /// - foregroundPID: PID read directly from the surface's current Ghostty runtime. + /// - Returns: Whether this runtime can safely receive continuation focus. + public func descendsFromForeground(_ owner: CodexWriterOwner, foregroundPID: Int) -> Bool { + guard foregroundPID > 0, foregroundPID <= Int(Int32.max), + let info = process(owner.pid), info.pbi_start_tvsec == owner.startSeconds, + info.pbi_start_tvusec == owner.startMicroseconds else { return false } + return ancestors(pid: owner.pid).contains(Int32(foregroundPID)) + } + + private func process(_ pid: Int32) -> proc_bsdinfo? { + var info = proc_bsdinfo() + let size = MemoryLayout.stride + guard proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, Int32(size)) == size, + info.pbi_status != UInt32(SZOMB) else { return nil } + return info + } + + private func holds(_ inspection: CodexWriterLockInspection, pid: Int32) -> Bool? { + guard let device = inspection.device, let inode = inspection.inode else { return nil } + let bytes = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0) + guard bytes > 0, bytes <= 4096 * MemoryLayout.stride else { return errno == ESRCH ? false : nil } + var fds = [proc_fdinfo](repeating: proc_fdinfo(), count: Int(bytes) / MemoryLayout.stride + 64) + let used = fds.withUnsafeMutableBytes { proc_pidinfo(pid, PROC_PIDLISTFDS, 0, $0.baseAddress, Int32($0.count)) } + guard used > 0, used < fds.count * MemoryLayout.stride else { return errno == ESRCH ? false : nil } + for fd in fds.prefix(Int(used) / MemoryLayout.stride) + where fd.proc_fdtype == UInt32(PROX_FDTYPE_VNODE) { + var vnode = vnode_fdinfo() + let size = MemoryLayout.stride + guard proc_pidfdinfo(pid, fd.proc_fd, PROC_PIDFDVNODEINFO, &vnode, Int32(size)) == size else { + if errno == EBADF || errno == ESRCH { continue } + return nil + } + if vnode.pvi.vi_stat.vst_dev == device, vnode.pvi.vi_stat.vst_ino == inode { return true } + } + return false + } + + private func owner(pid: Int32, info: proc_bsdinfo) -> CodexWriterOwner? { + var buffer = [CChar](repeating: 0, count: 4096) + let length = buffer.withUnsafeMutableBytes { proc_pidpath(pid, $0.baseAddress, UInt32($0.count)) } + guard length > 0 else { return nil } + let executable = buffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) } + var cwdInfo = proc_vnodepathinfo() + let cwdSize = MemoryLayout.stride + let cwd: String? = if proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &cwdInfo, Int32(cwdSize)) == cwdSize { + withUnsafeBytes(of: cwdInfo.pvi_cdir.vip_path) { raw in + String(cString: raw.baseAddress!.assumingMemoryBound(to: CChar.self)) + } + } else { nil } + return CodexWriterOwner( + pid: pid, startSeconds: info.pbi_start_tvsec, startMicroseconds: info.pbi_start_tvusec, + executable: executable, workingDirectory: cwd, ttyDevice: ttyDevice(info), ancestorPIDs: ancestors(pid: pid) + ) + } + + private func ttyDevice(_ info: proc_bsdinfo) -> Int64? { + let device = Int64(info.e_tdev) + return device > 0 && device != Int64(UInt32.max) ? device : nil + } + + private func ancestors(pid: Int32) -> Set { + var result: Set = [] + var next = pid + for _ in 0..<64 { + guard next > 1, result.insert(next).inserted, let info = process(next) else { break } + next = Int32(info.pbi_ppid) + } + return result + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterRestoreInspection.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterRestoreInspection.swift new file mode 100644 index 000000000000..46ab2853dc7a --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterRestoreInspection.swift @@ -0,0 +1,31 @@ +/// A preflight result shared by terminal execution and Vault continuation. +public struct CodexWriterRestoreInspection: Sendable { + /// Local kernel lock evidence, or nil when the final argv selects a remote provider. + public let lock: CodexWriterLockInspection? + /// Current descriptor holders. Empty when discovery is inconclusive or the lock changed. + public let owners: [CodexWriterOwner] + private let ownerScanComplete: Bool + + init(lock: CodexWriterLockInspection?, owners: [CodexWriterOwner], ownerScanComplete: Bool = true) { + self.lock = lock + self.owners = owners + self.ownerScanComplete = ownerScanComplete + } + + /// Whether the local ownership preflight permits process startup. + public var permitsLaunch: Bool { lock == nil || lock?.state == .available } + + /// Finds one runtime in the unique descriptor holder's observed ancestry. + /// Revalidate both the process and runtime generations before navigating. + /// - Parameter surfaces: Candidates from current local terminal runtimes. + /// - Returns: One unambiguous candidate, or nil for missing/duplicate evidence. + public func mappedSurface(in surfaces: [CodexWriterSurfaceIdentity]) -> CodexWriterSurfaceIdentity? { + guard lock?.state == .active, ownerScanComplete, owners.count == 1, let owner = owners.first else { return nil } + let matches = surfaces.filter { + $0.foregroundPID > 1 && $0.foregroundPID <= Int(Int32.max) + && $0.ttyDevice > 0 && owner.ttyDevice == $0.ttyDevice + && owner.ancestorPIDs.contains(Int32($0.foregroundPID)) + } + return matches.count == 1 ? matches.first : nil + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterRestorePreflight.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterRestorePreflight.swift new file mode 100644 index 000000000000..111d436efa7d --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterRestorePreflight.swift @@ -0,0 +1,80 @@ +import Foundation + +/// Checks the account and kernel lock of an already planned Codex resume. +/// +/// Both the CLI execution boundary and Vault use this policy. It is advisory: +/// Codex must still acquire its own lock atomically after cmux releases the probe. +/// The synchronous API supports exec-based CLI callers; UI callers must run the +/// bounded filesystem/process inspection outside the main actor. +public struct CodexWriterRestorePreflight: Sendable { + private let lockInspector: CodexWriterLockInspector + private let ownerLookup: @Sendable (CodexWriterLockInspection) -> CodexWriterOwnerScan + + /// Creates a preflight using read-only kernel process and file inspection. + /// - Parameter ownerLookup: Descriptor discovery dependency, called only for an active lock. + public init( + ownerLookup: @escaping @Sendable (CodexWriterLockInspection) -> CodexWriterOwnerScan = { CodexWriterProcessInspector().owners(for: $0) } + ) { + lockInspector = CodexWriterLockInspector() + self.ownerLookup = ownerLookup + } + + /// Inspects the effective launch, never the app's ambient account or a saved tty label. + /// + /// - Parameters: + /// - sessionID: The validated thread UUID to resume. + /// - arguments: Final process argv, including the executable. + /// - environment: Complete environment the child will actually receive. + /// - workingDirectory: Actual cwd after applying the saved directory fallback. + /// - fallbackHome: User home when the child has no HOME variable. + /// - Returns: The local lock and holder evidence, or no inspection for remote Codex. + public func inspect( + sessionID: String, + arguments: [String], + environment: [String: String], + workingDirectory: String, + fallbackHome: String + ) -> CodexWriterRestoreInspection { + guard !usesRemoteProvider(arguments: arguments) else { + return CodexWriterRestoreInspection(lock: nil, owners: []) + } + // Codex treats CODEX_HOME as a literal path (no shell tilde expansion). + // Resolve relative paths against the actual child cwd, not PWD metadata. + let explicitHome = environment["CODEX_HOME"].flatMap { $0.isEmpty ? nil : $0 } + let userHome = environment["HOME"].flatMap { $0.isEmpty ? nil : $0 } ?? fallbackHome + let rawHome = explicitHome ?? userHome + "/.codex" + // Leave symlink/.. traversal to the kernel, just as Codex does. + let home = rawHome.hasPrefix("/") ? rawHome : workingDirectory + "/" + rawHome + let first = lockInspector.inspect(sessionID: sessionID, codexHome: home) + guard first.state == .active else { + return CodexWriterRestoreInspection(lock: first, owners: []) + } + let scan = ownerLookup(first) + // The writer can exit while descriptors are being inspected. Never + // navigate or block based on a lock that has since been released. + let current = lockInspector.inspect(sessionID: sessionID, codexHome: home) + let sameLock = current.state == .active + && current.device == first.device && current.inode == first.inode + return CodexWriterRestoreInspection( + lock: current, owners: sameLock ? scan.owners : [], ownerScanComplete: sameLock && scan.isComplete + ) + } + + /// Recognizes the remote endpoint option without interpreting option values or prompts as flags. + /// - Parameter arguments: Codex argv, including the executable. + /// - Returns: Whether ownership belongs to a remote app-server, not the local home. + public func usesRemoteProvider(arguments: [String]) -> Bool { + var index = 1 + while index < arguments.count { + let argument = arguments[index] + if argument == "--" { return false } + if argument == "--remote" || argument.hasPrefix("--remote=") { return true } + if argument.hasPrefix("-") { + index += AgentLaunchSanitizer.optionWidth(arguments, index: index, policy: AgentLaunchSanitizer.codexPolicy) + } else { + index += 1 + } + } + return false + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterSurfaceIdentity.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterSurfaceIdentity.swift new file mode 100644 index 000000000000..5917b3d0fb3d --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/CodexWriterSurfaceIdentity.swift @@ -0,0 +1,30 @@ +import Foundation + +/// A navigation candidate read directly from a live terminal runtime, not saved metadata. +public struct CodexWriterSurfaceIdentity: Equatable, Sendable { + /// Workspace or rendered Dock that currently owns the surface. + public let containerID: UUID + /// Panel owning the runtime. + public let surfaceID: UUID + /// Runtime generation to revalidate after asynchronous process inspection. + public let generation: UInt64 + /// Foreground process read from that runtime, never a stored tty label. + public let foregroundPID: Int + /// Kernel device captured for this exact runtime's PTY lifecycle. + public let ttyDevice: Int64 + + /// Captures the identity of a live local terminal candidate. + /// - Parameters: + /// - containerID: Current owning container. + /// - surfaceID: Current owning panel. + /// - generation: Current terminal runtime generation. + /// - foregroundPID: Current foreground process identifier. + /// - ttyDevice: Current runtime's controlling-terminal device, not a saved name. + public init(containerID: UUID, surfaceID: UUID, generation: UInt64, foregroundPID: Int, ttyDevice: Int64) { + self.containerID = containerID + self.surfaceID = surfaceID + self.generation = generation + self.foregroundPID = foregroundPID + self.ttyDevice = ttyDevice + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentRestoreLaunchTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentRestoreLaunchTests.swift index 0ad18413728c..07731fae0b59 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentRestoreLaunchTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentRestoreLaunchTests.swift @@ -141,6 +141,33 @@ import Testing #expect(invocation.arguments.contains("-lc") == false) } + @Test func codexWriterBindingUsesTrimmedCheckpointID() throws { + let request = AgentRestoreRequest( + mode: .resumeAgent, + kind: "codex", + checkpointID: " \(sessionID) \n", + source: "agent-hook", + workingDirectory: "/tmp/codex", + environment: [:], + launchCommand: AgentLaunchCommand( + launcher: "codex", + arguments: ["codex"], + workingDirectory: "/tmp/codex" + ), + preparedArguments: nil, + observedPermissionMode: nil + ) + + let invocation = try #require( + AgentRestorePlanner(isExecutableFile: { _ in false }).invocation( + for: request, + ambientEnvironment: ["PATH": "/usr/bin:/bin"] + ) + ) + + #expect(invocation.codexResumeSessionID == sessionID) + } + @Test func structuredCodexRestoreCanonicalizesRelativeHomeFromLaunchDirectory() throws { let launchDirectory = "/tmp/codex-launch-root/repository" let restoredDirectory = "/tmp/codex-launch-root/repository/worktree" diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterLockInspectionTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterLockInspectionTests.swift new file mode 100644 index 000000000000..e97ba5bbb9b3 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterLockInspectionTests.swift @@ -0,0 +1,182 @@ +#if canImport(Darwin) +import Darwin +#endif +import Foundation +import Testing +@testable import CMUXAgentLaunch + +@Suite(.serialized) +struct CodexWriterLockInspectionTests { + @Test("an existing but unlocked lock file is available") + func unlockedFileIsNotTreatedAsAnActiveWriter() throws { + let fixture = try Fixture() + defer { fixture.remove() } + try fixture.createLockFile(for: fixture.sessionID) + + let inspection = CodexWriterLockInspector().inspect( + sessionID: fixture.sessionID, + codexHome: fixture.codexHome.path + ) + + #expect(inspection.state == .available) + #expect(FileManager.default.fileExists(atPath: inspection.lockPath)) + } + + @Test("a held lock is reported active without changing the file") + func heldLockIsActiveAndPreserved() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let descriptor = try fixture.holdLock(for: fixture.sessionID) + defer { + _ = flock(descriptor, LOCK_UN) + close(descriptor) + } + + let inspection = CodexWriterLockInspector().inspect( + sessionID: fixture.sessionID, + codexHome: fixture.codexHome.path + ) + + #expect(inspection.state == .active) + #expect(FileManager.default.fileExists(atPath: inspection.lockPath)) + } + + @Test("a released lock becomes available on the next probe") + func releasedLockIsAvailable() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let descriptor = try fixture.holdLock(for: fixture.sessionID) + + #expect( + CodexWriterLockInspector().inspect( + sessionID: fixture.sessionID, + codexHome: fixture.codexHome.path + ).state == .active + ) + + #expect(flock(descriptor, LOCK_UN) == 0) + close(descriptor) + + #expect( + CodexWriterLockInspector().inspect( + sessionID: fixture.sessionID, + codexHome: fixture.codexHome.path + ).state == .available + ) + } + + @Test("writer locks are isolated by effective Codex home") + func homesAreIsolated() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let otherHome = fixture.root.appendingPathComponent("other-codex", isDirectory: true) + try FileManager.default.createDirectory( + at: otherHome.appendingPathComponent("thread-writer-locks", isDirectory: true), + withIntermediateDirectories: true + ) + let descriptor = try fixture.holdLock(for: fixture.sessionID) + defer { + _ = flock(descriptor, LOCK_UN) + close(descriptor) + } + + let inspector = CodexWriterLockInspector() + #expect( + inspector.inspect(sessionID: fixture.sessionID, codexHome: fixture.codexHome.path).state + == .active + ) + #expect( + inspector.inspect(sessionID: fixture.sessionID, codexHome: otherHome.path).state + == .available + ) + } + + @Test("a missing lock does not create a lock file") + func missingLockIsAvailable() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let result = CodexWriterLockInspector().inspect(sessionID: fixture.sessionID, codexHome: fixture.codexHome.path) + #expect(result.state == .available) + #expect(!FileManager.default.fileExists(atPath: result.lockPath)) + } + + @Test("malformed identities and nonregular lock paths fail closed") + func invalidLockIsUnavailable() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let inspector = CodexWriterLockInspector() + #expect(inspector.inspect(sessionID: "../escape", codexHome: fixture.codexHome.path).state == .unavailable) + let path = fixture.codexHome.appendingPathComponent("thread-writer-locks/\(fixture.sessionID).lock") + try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true) + #expect(inspector.inspect(sessionID: fixture.sessionID, codexHome: fixture.codexHome.path).state == .unavailable) + } + + @Test("a lock-file symlink cannot authorize startup") + func symlinkLockIsUnavailable() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let path = fixture.codexHome.appendingPathComponent("thread-writer-locks/\(fixture.sessionID).lock") + try FileManager.default.createSymbolicLink(at: path, withDestinationURL: fixture.root.appendingPathComponent("missing")) + #expect(CodexWriterLockInspector().inspect(sessionID: fixture.sessionID, codexHome: fixture.codexHome.path).state == .unavailable) + } + + @Test("a home symlink uses the same kernel lock") + func homeAliasIsIsolatedByInode() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let descriptor = try fixture.holdLock(for: fixture.sessionID) + defer { close(descriptor) } + let alias = fixture.root.appendingPathComponent("account-alias") + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: fixture.codexHome) + #expect(CodexWriterLockInspector().inspect(sessionID: fixture.sessionID.uppercased(), codexHome: alias.path).state == .active) + } + + private static let fixtureSessionID = "01a06e0d-8793-7f33-b044-2b49a10c2260" + private let fixtureSessionID = Self.fixtureSessionID + + private final class Fixture { + let root: URL + let codexHome: URL + let sessionID = CodexWriterLockInspectionTests.fixtureSessionID + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-writer-\(UUID().uuidString)", isDirectory: true) + codexHome = root.appendingPathComponent("codex", isDirectory: true) + try FileManager.default.createDirectory( + at: codexHome.appendingPathComponent("thread-writer-locks", isDirectory: true), + withIntermediateDirectories: true + ) + } + + func createLockFile(for sessionID: String) throws { + let path = codexHome + .appendingPathComponent("thread-writer-locks", isDirectory: true) + .appendingPathComponent(sessionID + ".lock", isDirectory: false) + guard FileManager.default.createFile(atPath: path.path, contents: Data()) else { + throw FixtureError.file + } + } + + func holdLock(for sessionID: String) throws -> Int32 { + let path = codexHome + .appendingPathComponent("thread-writer-locks", isDirectory: true) + .appendingPathComponent(sessionID + ".lock", isDirectory: false) + let descriptor = open(path.path, O_CREAT | O_RDWR | O_CLOEXEC, 0o600) + guard descriptor >= 0, flock(descriptor, LOCK_EX | LOCK_NB) == 0 else { + if descriptor >= 0 { close(descriptor) } + throw FixtureError.lock + } + return descriptor + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } + + private enum FixtureError: Error { + case file + case lock + } + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterRestorePreflightTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterRestorePreflightTests.swift new file mode 100644 index 000000000000..0da97d74c947 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexWriterRestorePreflightTests.swift @@ -0,0 +1,147 @@ +import Darwin +import Foundation +import Testing +@testable import CMUXAgentLaunch + +struct CodexWriterRestorePreflightTests { + private let sessionID = "01a06e0d-8793-7f33-b044-2b49a10c2260" + + @Test("a writer released during discovery does not block or focus a stale owner") + func releaseDuringDiscovery() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let fd = try fixture.hold(sessionID) + defer { close(fd) } + let service = CodexWriterRestorePreflight { _ in + _ = flock(fd, LOCK_UN) + return CodexWriterOwnerScan(owners: [], isComplete: true) + } + let result = service.inspect( + sessionID: sessionID, arguments: ["codex", "resume", sessionID], + environment: ["CODEX_HOME": fixture.home.path], workingDirectory: fixture.root.path, + fallbackHome: fixture.root.path + ) + #expect(result.permitsLaunch) + #expect(result.owners.isEmpty) + #expect(result.mappedSurface(in: []) == nil) + } + + @Test("the final child cwd resolves a relative account instead of PWD or fallback HOME") + func relativeHome() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let fd = try fixture.hold(sessionID) + defer { close(fd) } + let service = CodexWriterRestorePreflight { _ in CodexWriterOwnerScan(owners: [], isComplete: false) } + let result = service.inspect( + sessionID: sessionID, arguments: ["codex", "resume", sessionID], + environment: ["CODEX_HOME": "account", "HOME": "/unrelated", "PWD": "/stale"], + workingDirectory: fixture.root.path, fallbackHome: "/another" + ) + #expect(!result.permitsLaunch) + #expect(result.lock?.state == .active) + } + + @Test("remote flags are recognized only as options", arguments: [ + (["codex", "--remote", "ws://host", "resume", "thread"], true), + (["codex", "resume", "thread", "--remote=ws://host"], true), + (["codex", "-c", "--remote=not-an-option", "resume", "thread"], false), + (["codex", "--model", "--remote", "resume", "thread"], false), + (["codex", "resume", "thread", "--", "--remote=prompt"], false), + (["codex", "resume", "thread", "ask about --remote=endpoint"], false), + ]) + func remoteScope(arguments: [String], expected: Bool) { + #expect(CodexWriterRestorePreflight().usesRemoteProvider(arguments: arguments) == expected) + } + + @Test("ambiguous or incomplete ownership never maps to a surface") + func conservativeMapping() { + let lock = CodexWriterLockInspection(state: .active, codexHome: "/account", lockPath: "/account/lock", device: 1, inode: 2) + let owner = CodexWriterOwner(pid: 300, startSeconds: 10, startMicroseconds: 2, executable: "/codex", workingDirectory: "/project", ttyDevice: 123, ancestorPIDs: [300, 200, 100]) + let target = CodexWriterSurfaceIdentity(containerID: UUID(), surfaceID: UUID(), generation: 4, foregroundPID: 200, ttyDevice: 123) + let stale = CodexWriterSurfaceIdentity(containerID: target.containerID, surfaceID: target.surfaceID, generation: 3, foregroundPID: 999, ttyDevice: 123) + let wrongTTY = CodexWriterSurfaceIdentity(containerID: target.containerID, surfaceID: target.surfaceID, generation: 4, foregroundPID: 200, ttyDevice: 456) + let result = CodexWriterRestoreInspection(lock: lock, owners: [owner]) + #expect(result.mappedSurface(in: [target]) == target) + #expect(result.mappedSurface(in: [stale]) == nil) + #expect(result.mappedSurface(in: [wrongTTY]) == nil) + #expect(result.mappedSurface(in: [target, target]) == nil) + #expect(CodexWriterRestoreInspection(lock: lock, owners: []).mappedSurface(in: [target]) == nil) + #expect(CodexWriterRestoreInspection(lock: lock, owners: [owner, owner]).mappedSurface(in: [target]) == nil) + #expect(CodexWriterRestoreInspection(lock: lock, owners: [owner], ownerScanComplete: false).mappedSurface(in: [target]) == nil) + let released = CodexWriterLockInspection(state: .available, codexHome: "/account", lockPath: "/account/lock", device: 1, inode: 2) + #expect(CodexWriterRestoreInspection(lock: released, owners: [owner]).mappedSurface(in: [target]) == nil) + } + + @Test("live holder generation is checked before continuation") + func processGeneration() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let fd = try fixture.hold(sessionID) + defer { close(fd) } + let lock = CodexWriterLockInspector().inspect(sessionID: sessionID, codexHome: fixture.home.path) + let inspector = CodexWriterProcessInspector() + let scan = inspector.owners(for: lock) + let owner = try #require(scan.owners.first(where: { $0.pid == getpid() })) + #expect(inspector.isCurrent(owner, inspection: lock)) + #expect(inspector.descendsFromForeground(owner, foregroundPID: Int(getpid()))) + let replaced = CodexWriterOwner(pid: owner.pid, startSeconds: owner.startSeconds + 1, startMicroseconds: owner.startMicroseconds, executable: owner.executable, workingDirectory: owner.workingDirectory, ttyDevice: owner.ttyDevice, ancestorPIDs: owner.ancestorPIDs) + #expect(!inspector.isCurrent(replaced, inspection: lock)) + #expect(!inspector.descendsFromForeground(replaced, foregroundPID: Int(getpid()))) + } + + @Test("verification home stays bound to the child account") + func verificationHomeIsReplayed() throws { + let request = AgentRestoreRequest( + mode: .resumeAgent, kind: "codex", checkpointID: sessionID, source: "test", + workingDirectory: "/project", environment: [:], + launchCommand: AgentLaunchCommand(arguments: ["codex"], verificationHome: "/saved-user"), + preparedArguments: nil, observedPermissionMode: nil + ) + let result = try #require(AgentRestorePlanner(isExecutableFile: { _ in false }).invocation( + for: request, ambientEnvironment: ["CODEX_HOME": "/ambient-account", "HOME": "/other-user"] + )) + #expect(result.environment["CODEX_HOME"] == "/saved-user/.codex") + #expect(result.codexResumeSessionID == sessionID) + } + + @Test("legacy literal commands preserve account and reject shell expansion") + func legacyScope() { + let prefix = "env CODEX_HOME='/accounts/codex user' /opt/codex resume " + let literal = CodexLegacyRestoreCommand(command: prefix + sessionID, sessionID: sessionID) + #expect(literal?.environment["CODEX_HOME"] == "/accounts/codex user") + #expect(literal?.arguments == ["/opt/codex", "resume", sessionID]) + for command in [ + "codex resume " + sessionID, + "CODEX_HOME=relative codex resume " + sessionID, + "CODEX_HOME=$OTHER codex resume " + sessionID, + prefix + UUID().uuidString, + prefix + sessionID + "; touch /tmp/never", + "cd /different && " + prefix + sessionID, + "env CODEX_HOME=$(pwd) codex resume " + sessionID, + ] { + #expect(CodexLegacyRestoreCommand(command: command, sessionID: sessionID) == nil) + } + } + + private struct Fixture { + let root: URL + let home: URL + + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-writer-policy-" + UUID().uuidString) + home = root.appendingPathComponent("account") + try FileManager.default.createDirectory(at: home.appendingPathComponent("thread-writer-locks"), withIntermediateDirectories: true) + } + + func hold(_ sessionID: String) throws -> Int32 { + let path = home.appendingPathComponent("thread-writer-locks/\(sessionID).lock").path + let fd = open(path, O_CREAT | O_RDWR | O_CLOEXEC, 0o600) + try #require(fd >= 0) + guard flock(fd, LOCK_EX | LOCK_NB) == 0 else { close(fd); throw CocoaError(.fileWriteUnknown) } + return fd + } + + func remove() { try? FileManager.default.removeItem(at: root) } + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 0106e832cfc2..4f20cb3a47b8 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -69851,6 +69851,91 @@ } } }, + "codex.restore.activeWriter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This Codex session already has an active writer. Continue in the original terminal, or exit that Codex session normally before retrying. cmux did not remove the lock or start another writer." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この Codex セッションにはアクティブなライターがあります。元のターミナルで続行するか、その Codex セッションを通常の方法で終了してから再試行してください。cmux はロックを削除せず、別のライターも起動していません。" + } + } + } + }, + "codex.restore.legacyScopeUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux cannot safely check ownership for this older shell-only Codex restore. No writer was started. Continue in the original terminal, or exit that session normally before retrying." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この古いシェルコマンド形式の Codex 復元では、cmux は所有者を安全に確認できません。ライターは起動していません。元のターミナルで続行するか、そのセッションを通常の方法で終了してから再試行してください。" + } + } + } + }, + "codex.restore.writerCheckUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux could not verify the Codex session owner. No new writer was started. Continue in the original terminal, or retry after checking the Codex account configuration." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex セッションの所有者を確認できませんでした。新しいライターは起動していません。元のターミナルで続行するか、Codex アカウント設定を確認してから再試行してください。" + } + } + } + }, + "sessionIndex.codex.activeWriter.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex session is already open" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex セッションはすでに開いています" + } + } + } + }, + "sessionIndex.codex.writerCheckUnavailable.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex session could not be checked" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex セッションを確認できませんでした" + } + } + } + }, "command.addWorkspaceChecklistItem.title": { "extractionState": "manual", "localizations": { diff --git a/Sources/CodexWriterRestoreMessage.swift b/Sources/CodexWriterRestoreMessage.swift new file mode 100644 index 000000000000..004a450ca871 --- /dev/null +++ b/Sources/CodexWriterRestoreMessage.swift @@ -0,0 +1,21 @@ +import CMUXAgentLaunch +import Foundation + +/// Localized ownership diagnostics shared by Vault and the restore CLI. +struct CodexWriterRestoreMessage { + let inspection: CodexWriterRestoreInspection + + var title: String { + if inspection.lock?.state == .active { + return String(localized: "sessionIndex.codex.activeWriter.title", defaultValue: "Codex session is already open") + } + return String(localized: "sessionIndex.codex.writerCheckUnavailable.title", defaultValue: "Codex session could not be checked") + } + + var text: String { + if inspection.lock?.state != .active { + return String(localized: "codex.restore.writerCheckUnavailable", defaultValue: "cmux could not verify the Codex session owner. No new writer was started. Continue in the original terminal, or retry after checking the Codex account configuration.") + } + return String(localized: "codex.restore.activeWriter", defaultValue: "This Codex session already has an active writer. Continue in the original terminal, or exit that Codex session normally before retrying. cmux did not remove the lock or start another writer.") + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 0cbd6e82dcb7..58ce8fee8102 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -843,6 +843,7 @@ struct ContentView: View { case searchIndexBuild case search case forkableAgentAvailability(String) + case sessionRestore } var updateViewModel: UpdateStateModel @@ -2394,11 +2395,17 @@ struct ContentView: View { } private func resumeSession(entry: SessionEntry) { - SessionEntryResumeCoordinator.resume(entry, tabManager: tabManager) + let tabManager = tabManager + commandPaletteTaskStore.replaceOnMainActor(.sessionRestore) { + _ = await SessionEntryResumeCoordinator(tabManager: tabManager).resume(entry) + } } private func openSession(entry: SessionEntry) { - SessionEntryResumeCoordinator.open(entry, tabManager: tabManager) + let tabManager = tabManager + commandPaletteTaskStore.replaceOnMainActor(.sessionRestore) { + await SessionEntryResumeCoordinator(tabManager: tabManager).open(entry) + } } func openRightSidebarToolPane(_ mode: RightSidebarMode) { @@ -3466,6 +3473,7 @@ struct ContentView: View { sidebarDragStartWidth = nil } cancelCommandPaletteForkableAgentProbeResultExpiryRefresh() + commandPaletteTaskStore.cancel(.sessionRestore) removeSidebarResizerPointerMonitor() }) diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index b50cf6941a17..5e0c72fd527e 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -116,6 +116,10 @@ extension RightSidebarMode { /// Right sidebar root view. Hosts a segmented mode picker plus the active panel. struct RightSidebarPanelView: View { + private enum SessionFocusTaskKey: Hashable, Sendable { + case focus + } + @ObservedObject var tabManager: TabManager @ObservedObject var fileExplorerStore: FileExplorerStore @ObservedObject var fileExplorerState: FileExplorerState @@ -158,6 +162,7 @@ struct RightSidebarPanelView: View { /// the remote host swaps files in place on one client, so a shared client /// would make the two rails fight over one worker process. @State private var customSidebarWorkerClient: RenderWorkerClient? + @State private var sessionFocusTaskStore = MainActorTaskStore() @State private var managedPolicyRevision = 0 // Re-reading the observable store inside modeBar causes SwiftUI to @@ -207,6 +212,13 @@ struct RightSidebarPanelView: View { alwaysShowShortcutHints || (showModifierHoldHints && focusShortcutHintMonitor.isModifierPressed) } + private func focusSession(_ entry: SessionEntry) { + let tabManager = tabManager + sessionFocusTaskStore.replaceOnMainActor(.focus) { + _ = await SessionEntryResumeCoordinator(tabManager: tabManager).focusIfActive(entry) + } + } + private func startShortcutHintMonitorsIfNeeded() { guard showModifierHoldHints else { stopShortcutHintMonitors() @@ -258,6 +270,7 @@ struct RightSidebarPanelView: View { } .onDisappear { stopShortcutHintMonitors() + sessionFocusTaskStore.cancel(.focus) } .onChange(of: showModifierHoldHints) { _, _ in startShortcutHintMonitorsIfNeeded() @@ -265,6 +278,9 @@ struct RightSidebarPanelView: View { .onChange(of: fileExplorerState.isVisible) { _, visible in if visible { hasMountedRightSidebarContent = true } } + .onChange(of: fileExplorerState.mode) { _, _ in + sessionFocusTaskStore.cancel(.focus) + } .onChange(of: feedEnabled) { _, _ in refreshModeAvailabilityAndFocusIfNeeded() } .onChange(of: dockEnabled) { _, _ in refreshModeAvailabilityAndFocusIfNeeded() } .onChange(of: cloudMachinesBetaEnabled) { _, _ in refreshModeAvailabilityAndFocusIfNeeded() } @@ -496,10 +512,8 @@ struct RightSidebarPanelView: View { store: sessionIndexStore, onResume: onResumeSession, onOpen: onOpenSession, - activeSessionKeys: SessionEntryResumeCoordinator.inPaneSessionKeys(tabManager: tabManager), - onFocus: { entry in - _ = SessionEntryResumeCoordinator.focusIfActive(entry, tabManager: tabManager) - } + activeSessionKeys: SessionEntryResumeCoordinator(tabManager: tabManager).inPaneSessionKeys(), + onFocus: focusSession ) .onAppear { sessionIndexStore.setCurrentDirectoryIfChanged(sessionIndexDirectory) diff --git a/Sources/RightSidebarToolPanel.swift b/Sources/RightSidebarToolPanel.swift index 55a380c05ff2..403abadcd243 100644 --- a/Sources/RightSidebarToolPanel.swift +++ b/Sources/RightSidebarToolPanel.swift @@ -1,8 +1,14 @@ import AppKit import Combine import CmuxAppKitSupportUI +import CmuxFoundation import SwiftUI +private enum RightSidebarToolPanelTaskKey: Hashable, Sendable { + case remoteFilePreview + case sessionAction +} + @MainActor final class RightSidebarToolPanel: Panel, ObservableObject { let id: UUID @@ -19,6 +25,7 @@ final class RightSidebarToolPanel: Panel, ObservableObject { private var fileExplorerStateStorage: FileExplorerState? private var sessionIndexStoreStorage: SessionIndexStore? private var workspaceObservationCancellable: AnyCancellable? + private let actionTasks = MainActorTaskStore() init(workspace: Workspace, mode: RightSidebarMode) { self.id = UUID() @@ -27,7 +34,7 @@ final class RightSidebarToolPanel: Panel, ObservableObject { } deinit { - // Explicit no-op so future teardown has a single home. + // MainActorTaskStore deinit cancels every remaining action task. } var fileExplorerStore: FileExplorerStore { @@ -94,8 +101,7 @@ final class RightSidebarToolPanel: Panel, ObservableObject { return } if workspace.isRemoteWorkspace { - let store = fileExplorerStore - Task { [weak workspace, weak store] in + actionTasks.replaceOnMainActor(.remoteFilePreview) { [weak workspace, weak store = fileExplorerStore] in guard let workspace, let store else { return } do { let localURL = try await store.materializeRemoteFileForPreview(path: filePath) @@ -124,6 +130,7 @@ final class RightSidebarToolPanel: Panel, ObservableObject { } func close() { + actionTasks.cancel(where: { _ in true }) fileExplorerContainerView = nil sessionIndexFocusAnchorView = nil fileExplorerStoreStorage?.applyWorkspaceRoot(.none) @@ -131,6 +138,12 @@ final class RightSidebarToolPanel: Panel, ObservableObject { workspaceObservationCancellable = nil } + func runSessionAction( + _ action: @escaping @MainActor @Sendable () async -> Void + ) { + actionTasks.replaceOnMainActor(.sessionAction, with: action) + } + func focus() { switch mode { case .files: @@ -288,14 +301,20 @@ struct RightSidebarToolPanelView: View { SessionIndexView( store: panel.sessionIndexStore, onResume: { entry in - SessionEntryResumeCoordinator.resume(entry, tabManager: tabManager) + panel.runSessionAction { [tabManager] in + _ = await SessionEntryResumeCoordinator(tabManager: tabManager).resume(entry) + } }, onOpen: { entry in - SessionEntryResumeCoordinator.open(entry, tabManager: tabManager) + panel.runSessionAction { [tabManager] in + await SessionEntryResumeCoordinator(tabManager: tabManager).open(entry) + } }, - activeSessionKeys: SessionEntryResumeCoordinator.inPaneSessionKeys(tabManager: tabManager), + activeSessionKeys: SessionEntryResumeCoordinator(tabManager: tabManager).inPaneSessionKeys(), onFocus: { entry in - _ = SessionEntryResumeCoordinator.focusIfActive(entry, tabManager: tabManager) + panel.runSessionAction { [tabManager] in + _ = await SessionEntryResumeCoordinator(tabManager: tabManager).focusIfActive(entry) + } } ) .background( diff --git a/Sources/SessionEntry.swift b/Sources/SessionEntry.swift new file mode 100644 index 000000000000..d3204c86dd9b --- /dev/null +++ b/Sources/SessionEntry.swift @@ -0,0 +1,351 @@ +import CMUXAgentLaunch +import Foundation + +struct SessionEntry: Identifiable, Hashable, Sendable { + let id: String + let agent: SessionAgent + /// Native session identifier for the agent's CLI (used to build the resume command). + let sessionId: String + let title: String + let cwd: String? + let gitBranch: String? + let pullRequest: PullRequestLink? + let modified: Date + let fileURL: URL? + /// Provider state root captured by the index, independent of transcript symlinks. + let indexedCodexHome: String? + let specifics: AgentSpecifics + /// Session creation time when the source exposes it cheaply (file birth + /// time, SQL column); nil otherwise. + let created: Date? + /// Exact conversation message count when it is knowable without extra + /// scanning (whole file inside the metadata read cap, SQL count); nil when + /// unknown or approximate. + let messageCount: Int? + + init( + id: String, + agent: SessionAgent, + sessionId: String, + title: String, + cwd: String?, + gitBranch: String?, + pullRequest: PullRequestLink?, + modified: Date, + fileURL: URL?, + indexedCodexHome: String? = nil, + specifics: AgentSpecifics, + created: Date? = nil, + messageCount: Int? = nil + ) { + self.id = id + self.agent = agent + self.sessionId = sessionId + self.title = title + self.cwd = cwd + self.gitBranch = gitBranch + self.pullRequest = pullRequest + self.modified = modified + self.fileURL = fileURL + self.indexedCodexHome = indexedCodexHome + self.specifics = specifics + self.created = created + self.messageCount = messageCount + } + + var resumeWorkingDirectory: String? { + guard let cwd, !cwd.isEmpty else { return nil } + if case .registered(let registration, _) = specifics, + registration.cwd == .ignore { + return nil + } + return cwd + } + + func withClaudeConfigDirectoryForResume(_ configDirectory: String?) -> SessionEntry { + guard case let .claude(model, permissionMode, currentConfigDirectory) = specifics, + currentConfigDirectory != configDirectory else { + return self + } + return SessionEntry( + id: id, + agent: agent, + sessionId: sessionId, + title: title, + cwd: cwd, + gitBranch: gitBranch, + pullRequest: pullRequest, + modified: modified, + fileURL: fileURL, + specifics: .claude( + model: model, + permissionMode: permissionMode, + configDirectoryForResume: configDirectory + ), + created: created, + messageCount: messageCount + ) + } + + /// Shell command exposed by the Copy Resume Command menu item. + var copyResumeCommand: String? { + guard let command = copyResumeCommandWithoutWorkingDirectory else { return nil } + guard let cwd = resumeWorkingDirectory else { + return command + } + return TerminalStartupWorkingDirectoryPrefix.prefix(command, workingDirectory: cwd) + } + + private var copyResumeCommandWithoutWorkingDirectory: String? { + switch specifics { + case let .claude(model, permissionMode, configDirectoryForResume): + // Route through the wrapper resolver token so a manually-resumed claude session + // re-injects cmux hooks even when the command runs in a shell where the + // integration's PATH shim / `claude()` function are not active (e.g. the + // `$SHELL -lic` restore launcher). The token is POSIX-only and this command + // is typed into — and copy-pasted into — the user's own shell (fish/csh + // included), so the rendered command is wrapped in `/bin/sh -c '…'` to parse + // everywhere; the `cd` guard stays outside in `copyResumeCommand`. + // https://github.com/manaflow-ai/cmux/issues/5639 + var parts = ["\(AgentResumeArgv.claudeWrapperShellExecutableToken) --resume \(sessionId)"] + if let model, !model.isEmpty { + parts.append("--model \(Self.shellQuote(model))") + } + if let permissionMode, !permissionMode.isEmpty { + parts.append("--permission-mode \(Self.shellQuote(permissionMode))") + } + let environment = configDirectoryForResume.map { + ["CLAUDE_CONFIG_DIR": $0, "CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV": "1", "CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS": "CLAUDE_CONFIG_DIR"] + } ?? [:] + return AgentResumeArgv.portableClaudeResumeShellCommand( + posixCommand: Self.withShellEnvironment(environment, command: parts.joined(separator: " ")) + ) + case let .codex(model, approval, sandbox, effort): + // Route through the codex wrapper-resolver token so a manually- or + // auto-resumed codex session re-injects cmux hooks even when the + // command runs in a shell where the integration's PATH shim is not + // active (e.g. the `$SHELL -lic` restore launcher). Without this the + // bare `codex resume ` resolves to the real codex binary, + // bypassing cmux-codex-wrapper, so no SessionStart fires and the iOS + // GUI stays read-only. Mirror of the claude case: the token is + // POSIX-only and this command is typed into / copy-pasted into the + // user's own shell (fish/csh included), so the rendered command is + // wrapped in `/bin/sh -c '…'`; the `cd` guard stays outside in + // `copyResumeCommand`. https://github.com/manaflow-ai/cmux/issues/5639 + var parts = ["\(AgentResumeArgv.codexWrapperShellExecutableToken) resume \(sessionId)", AgentResumeArgv.codexUpdateCheckSuppressionOverride.joined(separator: " ")] + if let model, !model.isEmpty { + parts.append("-m \(Self.shellQuote(model))") + } + parts.append(contentsOf: Self.codexApprovalSandboxArgumentTokens( + approvalPolicy: approval, + sandboxMode: sandbox + ).map(Self.shellQuote)) + if let effort, !effort.isEmpty { + parts.append("-c model_reasoning_effort=\(Self.shellQuote(effort))") + } + return AgentResumeArgv.portableCodexResumeShellCommand( + posixCommand: Self.withShellEnvironment( + codexHomeForResume.map { ["CODEX_HOME": $0] } ?? [:], + command: parts.joined(separator: " ") + ) + ) + case let .grok(model, permissionMode, sandboxMode, grokHome): + var argv = ["grok", "-r", sessionId] + if let model, !model.isEmpty { + argv.append(contentsOf: ["-m", model]) + } + if let permissionMode, !permissionMode.isEmpty { + argv.append(contentsOf: ["--permission-mode", permissionMode]) + } + if let sandboxMode, !sandboxMode.isEmpty { + argv.append(contentsOf: ["--sandbox", sandboxMode]) + } + let environment = grokHome.flatMap { value -> [String: String]? in + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : ["GROK_HOME": trimmed] + } ?? [:] + return Self.singleQuotedShellCommand(environment: environment, argv: argv) + case let .opencode(providerModel, agentName): + var parts = ["opencode --session \(sessionId)"] + if let providerModel, !providerModel.isEmpty { + parts.append("-m \(Self.shellQuote(providerModel))") + } + if let agentName, !agentName.isEmpty { + parts.append("--agent \(Self.shellQuote(agentName))") + } + return parts.joined(separator: " ") + case .rovodev: + return "acli rovodev run --restore \(Self.shellQuote(sessionId))" + case let .hermesAgent(source, model, hermesHome): + return Self.hermesResumeCommand( + sessionId: sessionId, + source: source, + model: model, + hermesHome: hermesHome + ) + case .registered(let registration, let launchCommand): + if let command = AgentResumeCommandBuilder.resumeShellCommand( + kind: .custom(registration.id), + sessionId: sessionId, + launchCommand: launchCommand ?? AgentLaunchCommandSnapshot( + launcher: registration.id, + executablePath: nil, + arguments: [registration.defaultExecutable], + workingDirectory: resumeWorkingDirectory, + environment: nil, + capturedAt: nil, + source: "vault" + ), + workingDirectory: resumeWorkingDirectory, + registrationOverride: registration, + includeWorkingDirectoryPrefix: false + ) { + return command + } + return nil + } + } + + private static func withShellEnvironment( + _ environment: [String: String], + command: String + ) -> String { + let assignments = environment + .filter { key, _ in + key.range(of: #"^[A-Za-z_][A-Za-z0-9_]*$"#, options: .regularExpression) != nil + } + .sorted { $0.key < $1.key } + .map { key, value in "\(key)=\(shellQuote(value))" } + guard !assignments.isEmpty else { return command } + return "env \(assignments.joined(separator: " ")) \(command)" + } + + private static func singleQuotedShellCommand( + environment: [String: String], + argv: [String] + ) -> String { + var parts: [String] = [] + let assignments = environment + .filter { key, _ in + key.range(of: #"^[A-Za-z_][A-Za-z0-9_]*$"#, options: .regularExpression) != nil + } + .sorted { $0.key < $1.key } + .map { key, value in "\(key)=\(value)" } + if !assignments.isEmpty { + parts.append("env") + parts.append(contentsOf: assignments) + } + parts.append(contentsOf: argv) + return parts.map(Self.shellSingleQuote).joined(separator: " ") + } + + private static func shellSingleQuote(_ value: String) -> String { + TerminalStartupShellQuoting.singleQuoted(value) + } + + /// Single-quote a value for safe shell injection. Escapes embedded single quotes. + static func shellQuote(_ value: String) -> String { + TerminalStartupShellQuoting.shellToken(value, allowingBareASCII: true) + } + + var displayTitle: String { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + if agent == .claude { + if let title = Self.claudeDisplayTitle(from: trimmed) { + return title + } + if Self.isClaudeLocalCommandEnvelope(trimmed) { + return String(localized: "sessionIndex.localCommand", defaultValue: "Local command") + } + if Self.isClaudeSyntheticEnvelope(trimmed) { + return String(localized: "sessionIndex.untitled", defaultValue: "Untitled chat") + } + } + if trimmed.isEmpty { + return String(localized: "sessionIndex.untitled", defaultValue: "Untitled chat") + } + return trimmed + } + + static func claudeDisplayTitle(from raw: String, isMeta: Bool = false) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if isMeta || isClaudeSyntheticEnvelope(trimmed) { + return nil + } + if let commandTitle = claudeSlashCommandTitle(from: trimmed) { + return commandTitle + } + return trimmed + } + + private static func claudeSlashCommandTitle(from raw: String) -> String? { + let commandName = claudeTagValue("command-name", in: raw) + let commandMessage = claudeTagValue("command-message", in: raw) + var parts: [String] = [] + if let commandName { + parts.append(commandName) + } + if let commandMessage, + !isDuplicateClaudeCommandMessage(commandMessage, commandName: commandName) { + parts.append(commandMessage) + } + if let args = claudeTagValue("command-args", in: raw) { + parts.append(args) + } + return parts.isEmpty ? nil : parts.joined(separator: " ") + } + + private static func isDuplicateClaudeCommandMessage(_ message: String, commandName: String?) -> Bool { + guard let commandName else { return false } + let commandWithoutSlash = commandName.hasPrefix("/") + ? String(commandName.dropFirst()) + : commandName + return message.caseInsensitiveCompare(commandName) == .orderedSame + || message.caseInsensitiveCompare(commandWithoutSlash) == .orderedSame + } + + private static func claudeTagValue(_ tag: String, in raw: String) -> String? { + let open = "<\(tag)>" + let close = "" + guard let start = raw.range(of: open), + let end = raw.range(of: close, range: start.upperBound.. Bool { + isClaudeLocalCommandEnvelope(raw) + || raw.hasPrefix("") + } + + private static func isClaudeLocalCommandEnvelope(_ raw: String) -> Bool { + raw.hasPrefix(" String { + value.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") + } + + var cwdLabel: String? { + guard let cwd, !cwd.isEmpty else { return nil } + let home = NSHomeDirectory() + // Compare on a path boundary so /Users/al doesn't get matched by a + // home of /Users/alice (would render as "~ice/foo"). + if cwd == home { + return "~" + } + if cwd.hasPrefix(home + "/") { + return "~" + cwd.dropFirst(home.count) + } + return cwd + } + + var cwdBasename: String? { + guard let cwd, !cwd.isEmpty else { return nil } + return (cwd as NSString).lastPathComponent + } +} diff --git a/Sources/SessionEntryCodexHome.swift b/Sources/SessionEntryCodexHome.swift new file mode 100644 index 000000000000..3af40256ae97 --- /dev/null +++ b/Sources/SessionEntryCodexHome.swift @@ -0,0 +1,25 @@ +import Foundation + +extension SessionEntry { + /// Returns the Codex state directory owning this transcript, when the + /// indexed path is under `sessions` or `archived_sessions`. + /// + /// Vault can index more than the default `~/.codex` account. Keeping the + /// owning home beside the structured restore snapshot prevents a later + /// ambient `CODEX_HOME` from switching the account during resume. + var codexHomeForResume: String? { + guard agent == .codex else { return nil } + if let indexedCodexHome { return indexedCodexHome } + guard let fileURL else { return nil } + // Keep the indexing path: the transcript can be symlinked to shared + // storage while the account's writer locks remain under its own home. + let path = fileURL.standardizedFileURL.path + for marker in ["/sessions/", "/archived_sessions/"] { + guard let range = path.range(of: marker, options: .backwards) else { continue } + let home = String(path[.. Bool { + guard entry.agent == .codex, let launch = entry.resumeLaunch, + let snapshot = launch.startupRestoreAgent else { return false } + let command = snapshot.launchCommand + let environment = ProcessInfo.processInfo.environment.merging(command?.environment ?? [:]) { _, saved in saved } + let cwd = launch.workingDirectory ?? FileManager.default.currentDirectoryPath + let fallbackHome = NSHomeDirectory() + let sessionID = entry.sessionId + let arguments = command?.arguments ?? ["codex"] + let task = Task.detached(priority: .userInitiated) { + CodexWriterRestorePreflight().inspect( + sessionID: sessionID, arguments: arguments, environment: environment, + workingDirectory: cwd, fallbackHome: fallbackHome + ) + } + let result = await withTaskCancellationHandler { await task.value } onCancel: { task.cancel() } + guard !Task.isCancelled else { return true } + if result.permitsLaunch { return false } + if let target = result.mappedSurface(in: codexWriterSurfaces()), + let owner = result.owners.first, let lock = result.lock { + let confirmation = Task.detached(priority: .userInitiated) { + let processes = CodexWriterProcessInspector() + return processes.isCurrent(owner, inspection: lock) + && processes.descendsFromForeground(owner, foregroundPID: target.foregroundPID) + && CodexWriterLockInspector().inspect(sessionID: sessionID, codexHome: lock.codexHome) == lock + } + let confirmed = await withTaskCancellationHandler { await confirmation.value } onCancel: { confirmation.cancel() } + guard !Task.isCancelled else { return true } + // The panel can move or restart while process I/O is in flight. + if confirmed, codexWriterSurfaces().contains(target) { + if let workspace = tabManager.tabs.first(where: { $0.id == target.containerID }) { + tabManager.focusTab(workspace.id, surfaceId: target.surfaceID) + return true + } + if let dock = tabManager.liveWindowDockStores.first(where: { $0.workspaceId == target.containerID }) { + dock.focusPanelFromDockInteraction(target.surfaceID, window: nil) + return true + } + } + } + let message = CodexWriterRestoreMessage(inspection: result) + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = message.title + alert.informativeText = message.text + alert.addButton(withTitle: String(localized: "alert.ok", defaultValue: "OK")) + _ = alert.runModal() + return true + } + + private func codexWriterSurfaces() -> [CodexWriterSurfaceIdentity] { + var candidates: [CodexWriterSurfaceIdentity] = [] + for workspace in tabManager.tabs + where !workspace.isRetiredFromOwningTabManager && !workspace.isRemoteWorkspace && !workspace.isRemoteTmuxMirror { + for (panelID, panel) in workspace.panels { + guard !workspace.isRemoteTerminalSurface(panelID), + let terminal = panel as? TerminalPanel, terminal.surface.hasLiveSurface, + let foregroundPID = terminal.surface.foregroundProcessID(), + let ttyDevice = terminal.surface.controllingTTYDeviceIdentifier else { continue } + candidates.append(CodexWriterSurfaceIdentity( + containerID: workspace.id, surfaceID: panelID, + generation: terminal.surface.runtimeSurfaceGeneration, foregroundPID: foregroundPID, ttyDevice: ttyDevice + )) + } + } + // Scope to this window's rendered Dock. Legacy workspace Docks and + // remote/mirrored surfaces have no safe continuation route here. + for dock in tabManager.liveWindowDockStores where dock.scope == .global && !dock.isRetired { + for (panelID, panel) in dock.panels { + guard !dock.terminalLinkIsRemoteTerminal(panelID), + let terminal = panel as? TerminalPanel, terminal.surface.hasLiveSurface, + let foregroundPID = terminal.surface.foregroundProcessID(), + let ttyDevice = terminal.surface.controllingTTYDeviceIdentifier else { continue } + candidates.append(CodexWriterSurfaceIdentity( + containerID: dock.workspaceId, surfaceID: panelID, + generation: terminal.surface.runtimeSurfaceGeneration, foregroundPID: foregroundPID, ttyDevice: ttyDevice + )) + } + } + return candidates + } +} diff --git a/Sources/SessionEntryResumeCoordinator.swift b/Sources/SessionEntryResumeCoordinator.swift new file mode 100644 index 000000000000..1fd9d90e333e --- /dev/null +++ b/Sources/SessionEntryResumeCoordinator.swift @@ -0,0 +1,164 @@ +import AppKit +import CMUXAgentLaunch +import Foundation + +@MainActor +struct SessionEntryResumeCoordinator { + let tabManager: TabManager + + @discardableResult + private func launchInNewWorkspace( + _ launch: SessionEntryResumeLaunch + ) -> Workspace? { + tabManager.addWorkspaceIfActive( + workingDirectory: launch.workingDirectory, + initialTerminalInput: launch.initialInput, + initialTerminalStartupRestoreAgent: launch.startupRestoreAgent + ) + } + + /// Returns the in-pane target for an indexed session, if one is currently + /// represented by a real surface in the tab manager. + /// + /// Keeping target discovery separate from the focus mutation lets the Vault + /// row expose an honest enabled/disabled state without focusing anything + /// while SwiftUI is rendering a context menu. + func activeTarget( + for entry: SessionEntry + ) -> (workspaceID: UUID, surfaceID: UUID)? { + let workspacesByID = Dictionary(uniqueKeysWithValues: tabManager.tabs.map { ($0.id, $0) }) + + // Prefer the tab manager's authoritative surface snapshots. This + // catches an open-but-idle session even while the process index is + // between refreshes. + for workspace in tabManager.tabs where entry.agent != .codex { + if let panel = workspace.restoredAgentSnapshotsByPanelId.first(where: { panelID, snapshot in + workspace.panels[panelID] != nil + && workspace.panelShellActivityStates[panelID] == .commandRunning + && snapshot.kind.rawValue == entry.agent.rawValue + && ManagedAgentSessionIdentity.sessionIDsMatch( + kind: entry.agent.rawValue, + lhs: snapshot.sessionId, + rhs: entry.sessionId + ) + }) { + return (workspace.id, panel.key) + } + } + + // Process-detected sessions can still be present in the live index + // before their snapshot has been projected into the tab manager. + guard let index = SharedLiveAgentIndex.shared.index, + let match = index.forkValidationEntries().first(where: { panelKey, observation in + observation.processLiveness == .running + && observation.snapshot.kind.rawValue == entry.agent.rawValue + && ManagedAgentSessionIdentity.sessionIDsMatch( + kind: entry.agent.rawValue, + lhs: observation.snapshot.sessionId, + rhs: entry.sessionId + ) + && workspacesByID[panelKey.workspaceId]?.panels[panelKey.panelId] != nil + }) else { + return nil + } + + return (match.0.workspaceId, match.0.panelId) + } + + /// Returns the managed-session identities currently represented by real + /// panes. This is a read-only presentation snapshot; it never focuses or + /// selects a workspace and is safe to hand across the Vault row boundary. + func inPaneSessionKeys() -> Set { + var keys: Set = [] + let workspacesByID = Dictionary(uniqueKeysWithValues: tabManager.tabs.map { ($0.id, $0) }) + for workspace in tabManager.tabs { + for (panelID, snapshot) in workspace.restoredAgentSnapshotsByPanelId + where workspace.panels[panelID] != nil && snapshot.kind.rawValue != "codex" + && workspace.panelShellActivityStates[panelID] == .commandRunning { + keys.insert( + VaultLiveSessionKeys.key( + kind: snapshot.kind.rawValue, + sessionID: snapshot.sessionId + ) + ) + } + } + // Cached live evidence is presentation-only. The Focus mutation always + // rechecks the actual lock and runtime; rendering performs no I/O. + if let index = SharedLiveAgentIndex.shared.index { + for (key, observation) in index.forkValidationEntries() + where observation.snapshot.kind.rawValue == "codex" && observation.processLiveness == .running { + guard !observation.processIDs.isEmpty, + workspacesByID[key.workspaceId]?.panels[key.panelId] != nil, + workspacesByID[key.workspaceId]?.panelShellActivityStates[key.panelId] == .commandRunning + else { continue } + keys.insert(VaultLiveSessionKeys.key(kind: "codex", sessionID: observation.snapshot.sessionId)) + } + } + return keys + } + + /// Opens an indexed session in a new split in the selected workspace. + /// + /// Open intentionally creates another split for other providers. Codex's + /// single-writer contract instead continues an exactly mapped live owner. + func open(_ entry: SessionEntry) async { + let destination = tabManager.selectedWorkspace?.id + guard !(await handleCodexWriterConflict(for: entry)), + !Task.isCancelled, tabManager.selectedWorkspace?.id == destination else { return } + guard let launch = entry.resumeLaunch else { return } + + guard let workspace = tabManager.selectedWorkspace, + !workspace.isRemoteWorkspace, + !workspace.isRemoteTmuxMirror, + let paneId = workspace.bonsplitController.focusedPaneId + ?? workspace.bonsplitController.allPaneIds.first else { + // A remote workspace cannot safely execute a local Vault restore + // command. If there is no usable local pane, fall back to the + // same isolated-workspace launch used by Resume. + _ = launchInNewWorkspace(launch) + return + } + + // A zoomed pane has no room to represent the new split until it is + // restored to the normal layout. + workspace.clearSplitZoom() + if workspace.splitPaneWithNewTerminal( + targetPane: paneId, + orientation: .horizontal, + insertFirst: false, + workingDirectory: launch.workingDirectory, + initialInput: launch.initialInput, + startupRestoreAgent: launch.startupRestoreAgent + ) == nil { + // Keep the action useful if the selected workspace retires between + // menu presentation and invocation. + _ = launchInNewWorkspace(launch) + } + } + + /// Focuses the current surface for `entry` when the live agent index still + /// points at a real panel in this tab manager. + @discardableResult + func focusIfActive(_ entry: SessionEntry) async -> Bool { + if entry.agent == .codex { + return await handleCodexWriterConflict(for: entry) + } + guard let target = activeTarget(for: entry) else { + return false + } + tabManager.focusTab(target.workspaceID, surfaceId: target.surfaceID) + return true + } + + @discardableResult + func resume(_ entry: SessionEntry) async -> Bool { + guard !(await handleCodexWriterConflict(for: entry)), !Task.isCancelled else { return false } + guard let launch = entry.resumeLaunch else { return false } + // Resume is deliberately workspace-scoped. It must remain predictable + // even when the selected workspace happens to share the session's cwd; + // Open Session is the separate action for a split in the current + // workspace. + return launchInNewWorkspace(launch) != nil + } +} diff --git a/Sources/SessionEntryResumeLaunch.swift b/Sources/SessionEntryResumeLaunch.swift index 35b90c7b6a3a..605d32e5bb2f 100644 --- a/Sources/SessionEntryResumeLaunch.swift +++ b/Sources/SessionEntryResumeLaunch.swift @@ -104,7 +104,7 @@ extension SessionEntry { } components = SessionEntryResumeSnapshotComponents( arguments: arguments, - environment: [:], + environment: codexHomeForResume.map { ["CODEX_HOME": $0] } ?? [:], registration: nil, permissionMode: nil ) diff --git a/Sources/SessionIndexModels.swift b/Sources/SessionIndexModels.swift index 3c15c7416565..d04afd0e0d33 100644 --- a/Sources/SessionIndexModels.swift +++ b/Sources/SessionIndexModels.swift @@ -254,346 +254,3 @@ enum ClaudeConfigurationRoot { return true } } - -struct SessionEntry: Identifiable, Hashable, Sendable { - let id: String - let agent: SessionAgent - /// Native session identifier for the agent's CLI (used to build the resume command). - let sessionId: String - let title: String - let cwd: String? - let gitBranch: String? - let pullRequest: PullRequestLink? - let modified: Date - let fileURL: URL? - let specifics: AgentSpecifics - /// Session creation time when the source exposes it cheaply (file birth - /// time, SQL column); nil otherwise. - let created: Date? - /// Exact conversation message count when it is knowable without extra - /// scanning (whole file inside the metadata read cap, SQL count); nil when - /// unknown or approximate. - let messageCount: Int? - - init( - id: String, - agent: SessionAgent, - sessionId: String, - title: String, - cwd: String?, - gitBranch: String?, - pullRequest: PullRequestLink?, - modified: Date, - fileURL: URL?, - specifics: AgentSpecifics, - created: Date? = nil, - messageCount: Int? = nil - ) { - self.id = id - self.agent = agent - self.sessionId = sessionId - self.title = title - self.cwd = cwd - self.gitBranch = gitBranch - self.pullRequest = pullRequest - self.modified = modified - self.fileURL = fileURL - self.specifics = specifics - self.created = created - self.messageCount = messageCount - } - - var resumeWorkingDirectory: String? { - guard let cwd, !cwd.isEmpty else { return nil } - if case .registered(let registration, _) = specifics, - registration.cwd == .ignore { - return nil - } - return cwd - } - - func withClaudeConfigDirectoryForResume(_ configDirectory: String?) -> SessionEntry { - guard case let .claude(model, permissionMode, currentConfigDirectory) = specifics, - currentConfigDirectory != configDirectory else { - return self - } - return SessionEntry( - id: id, - agent: agent, - sessionId: sessionId, - title: title, - cwd: cwd, - gitBranch: gitBranch, - pullRequest: pullRequest, - modified: modified, - fileURL: fileURL, - specifics: .claude( - model: model, - permissionMode: permissionMode, - configDirectoryForResume: configDirectory - ), - created: created, - messageCount: messageCount - ) - } - - /// Shell command exposed by the Copy Resume Command menu item. - var copyResumeCommand: String? { - guard let command = copyResumeCommandWithoutWorkingDirectory else { return nil } - guard let cwd = resumeWorkingDirectory else { - return command - } - return TerminalStartupWorkingDirectoryPrefix.prefix(command, workingDirectory: cwd) - } - - private var copyResumeCommandWithoutWorkingDirectory: String? { - switch specifics { - case let .claude(model, permissionMode, configDirectoryForResume): - // Route through the wrapper resolver token so a manually-resumed claude session - // re-injects cmux hooks even when the command runs in a shell where the - // integration's PATH shim / `claude()` function are not active (e.g. the - // `$SHELL -lic` restore launcher). The token is POSIX-only and this command - // is typed into — and copy-pasted into — the user's own shell (fish/csh - // included), so the rendered command is wrapped in `/bin/sh -c '…'` to parse - // everywhere; the `cd` guard stays outside in `copyResumeCommand`. - // https://github.com/manaflow-ai/cmux/issues/5639 - var parts = ["\(AgentResumeArgv.claudeWrapperShellExecutableToken) --resume \(sessionId)"] - if let model, !model.isEmpty { - parts.append("--model \(Self.shellQuote(model))") - } - if let permissionMode, !permissionMode.isEmpty { - parts.append("--permission-mode \(Self.shellQuote(permissionMode))") - } - let environment = configDirectoryForResume.map { - ["CLAUDE_CONFIG_DIR": $0, "CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV": "1", "CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS": "CLAUDE_CONFIG_DIR"] - } ?? [:] - return AgentResumeArgv.portableClaudeResumeShellCommand( - posixCommand: Self.withShellEnvironment(environment, command: parts.joined(separator: " ")) - ) - case let .codex(model, approval, sandbox, effort): - // Route through the codex wrapper-resolver token so a manually- or - // auto-resumed codex session re-injects cmux hooks even when the - // command runs in a shell where the integration's PATH shim is not - // active (e.g. the `$SHELL -lic` restore launcher). Without this the - // bare `codex resume ` resolves to the real codex binary, - // bypassing cmux-codex-wrapper, so no SessionStart fires and the iOS - // GUI stays read-only. Mirror of the claude case: the token is - // POSIX-only and this command is typed into / copy-pasted into the - // user's own shell (fish/csh included), so the rendered command is - // wrapped in `/bin/sh -c '…'`; the `cd` guard stays outside in - // `copyResumeCommand`. https://github.com/manaflow-ai/cmux/issues/5639 - var parts = ["\(AgentResumeArgv.codexWrapperShellExecutableToken) resume \(sessionId)", AgentResumeArgv.codexUpdateCheckSuppressionOverride.joined(separator: " ")] - if let model, !model.isEmpty { - parts.append("-m \(Self.shellQuote(model))") - } - parts.append(contentsOf: Self.codexApprovalSandboxArgumentTokens( - approvalPolicy: approval, - sandboxMode: sandbox - ).map(Self.shellQuote)) - if let effort, !effort.isEmpty { - parts.append("-c model_reasoning_effort=\(Self.shellQuote(effort))") - } - return AgentResumeArgv.portableCodexResumeShellCommand( - posixCommand: parts.joined(separator: " ") - ) - case let .grok(model, permissionMode, sandboxMode, grokHome): - var argv = ["grok", "-r", sessionId] - if let model, !model.isEmpty { - argv.append(contentsOf: ["-m", model]) - } - if let permissionMode, !permissionMode.isEmpty { - argv.append(contentsOf: ["--permission-mode", permissionMode]) - } - if let sandboxMode, !sandboxMode.isEmpty { - argv.append(contentsOf: ["--sandbox", sandboxMode]) - } - let environment = grokHome.flatMap { value -> [String: String]? in - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : ["GROK_HOME": trimmed] - } ?? [:] - return Self.singleQuotedShellCommand(environment: environment, argv: argv) - case let .opencode(providerModel, agentName): - var parts = ["opencode --session \(sessionId)"] - if let providerModel, !providerModel.isEmpty { - parts.append("-m \(Self.shellQuote(providerModel))") - } - if let agentName, !agentName.isEmpty { - parts.append("--agent \(Self.shellQuote(agentName))") - } - return parts.joined(separator: " ") - case .rovodev: - return "acli rovodev run --restore \(Self.shellQuote(sessionId))" - case let .hermesAgent(source, model, hermesHome): - return Self.hermesResumeCommand( - sessionId: sessionId, - source: source, - model: model, - hermesHome: hermesHome - ) - case .registered(let registration, let launchCommand): - let capturedLaunch = launchCommand ?? AgentLaunchCommandSnapshot( - launcher: registration.id, - executablePath: nil, - arguments: [registration.defaultExecutable], - workingDirectory: resumeWorkingDirectory, - environment: nil, - capturedAt: nil, - source: "vault" - ) - if let command = AgentResumeCommandBuilder.resumeShellCommand( - kind: .custom(registration.id), - sessionId: sessionId, - launchCommand: capturedLaunch, - workingDirectory: resumeWorkingDirectory, - registrationOverride: registration, - includeWorkingDirectoryPrefix: false - ) { - return command - } - return nil - } - } - - private static func withShellEnvironment( - _ environment: [String: String], - command: String - ) -> String { - let assignments = environment - .filter { key, _ in - key.range(of: #"^[A-Za-z_][A-Za-z0-9_]*$"#, options: .regularExpression) != nil - } - .sorted { $0.key < $1.key } - .map { key, value in "\(key)=\(shellQuote(value))" } - guard !assignments.isEmpty else { return command } - return "env \(assignments.joined(separator: " ")) \(command)" - } - - private static func singleQuotedShellCommand( - environment: [String: String], - argv: [String] - ) -> String { - var parts: [String] = [] - let assignments = environment - .filter { key, _ in - key.range(of: #"^[A-Za-z_][A-Za-z0-9_]*$"#, options: .regularExpression) != nil - } - .sorted { $0.key < $1.key } - .map { key, value in "\(key)=\(value)" } - if !assignments.isEmpty { - parts.append("env") - parts.append(contentsOf: assignments) - } - parts.append(contentsOf: argv) - return parts.map(Self.shellSingleQuote).joined(separator: " ") - } - - private static func shellSingleQuote(_ value: String) -> String { - TerminalStartupShellQuoting.singleQuoted(value) - } - - /// Single-quote a value for safe shell injection. Escapes embedded single quotes. - static func shellQuote(_ value: String) -> String { - TerminalStartupShellQuoting.shellToken(value, allowingBareASCII: true) - } - - var displayTitle: String { - let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) - if agent == .claude { - if let title = Self.claudeDisplayTitle(from: trimmed) { - return title - } - if Self.isClaudeLocalCommandEnvelope(trimmed) { - return String(localized: "sessionIndex.localCommand", defaultValue: "Local command") - } - if Self.isClaudeSyntheticEnvelope(trimmed) { - return String(localized: "sessionIndex.untitled", defaultValue: "Untitled chat") - } - } - if trimmed.isEmpty { - return String(localized: "sessionIndex.untitled", defaultValue: "Untitled chat") - } - return trimmed - } - - static func claudeDisplayTitle(from raw: String, isMeta: Bool = false) -> String? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if isMeta || isClaudeSyntheticEnvelope(trimmed) { - return nil - } - if let commandTitle = claudeSlashCommandTitle(from: trimmed) { - return commandTitle - } - return trimmed - } - - private static func claudeSlashCommandTitle(from raw: String) -> String? { - let commandName = claudeTagValue("command-name", in: raw) - let commandMessage = claudeTagValue("command-message", in: raw) - var parts: [String] = [] - if let commandName { - parts.append(commandName) - } - if let commandMessage, - !isDuplicateClaudeCommandMessage(commandMessage, commandName: commandName) { - parts.append(commandMessage) - } - if let args = claudeTagValue("command-args", in: raw) { - parts.append(args) - } - return parts.isEmpty ? nil : parts.joined(separator: " ") - } - - private static func isDuplicateClaudeCommandMessage(_ message: String, commandName: String?) -> Bool { - guard let commandName else { return false } - let commandWithoutSlash = commandName.hasPrefix("/") - ? String(commandName.dropFirst()) - : commandName - return message.caseInsensitiveCompare(commandName) == .orderedSame - || message.caseInsensitiveCompare(commandWithoutSlash) == .orderedSame - } - - private static func claudeTagValue(_ tag: String, in raw: String) -> String? { - let open = "<\(tag)>" - let close = "" - guard let start = raw.range(of: open), - let end = raw.range(of: close, range: start.upperBound.. Bool { - isClaudeLocalCommandEnvelope(raw) - || raw.hasPrefix("") - } - - private static func isClaudeLocalCommandEnvelope(_ raw: String) -> Bool { - raw.hasPrefix(" String { - value.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") - } - - var cwdLabel: String? { - guard let cwd, !cwd.isEmpty else { return nil } - let home = NSHomeDirectory() - // Compare on a path boundary so /Users/al doesn't get matched by a - // home of /Users/alice (would render as "~ice/foo"). - if cwd == home { - return "~" - } - if cwd.hasPrefix(home + "/") { - return "~" + cwd.dropFirst(home.count) - } - return cwd - } - - var cwdBasename: String? { - guard let cwd, !cwd.isEmpty else { return nil } - return (cwd as NSString).lastPathComponent - } -} diff --git a/Sources/SessionIndexStore+CodexSQL.swift b/Sources/SessionIndexStore+CodexSQL.swift index b282be2be3cb..99b7cdf260be 100644 --- a/Sources/SessionIndexStore+CodexSQL.swift +++ b/Sources/SessionIndexStore+CodexSQL.swift @@ -108,7 +108,7 @@ extension SessionIndexStore { )) } guard !needle.isEmpty else { - return records.map(codexEntry(from:)) + return records.map { codexEntry(from: $0, codexHome: (dbPath as NSString).deletingLastPathComponent) } } guard limit > 0 else { return [] } @@ -128,7 +128,7 @@ extension SessionIndexStore { ) guard matches else { continue } if matchedCount >= offset { - entries.append(codexEntry(from: record)) + entries.append(codexEntry(from: record, codexHome: (dbPath as NSString).deletingLastPathComponent)) if entries.count >= limit { break } } matchedCount += 1 @@ -159,7 +159,7 @@ extension SessionIndexStore { } #endif - nonisolated private static func codexEntry(from record: CodexThreadRecord) -> SessionEntry { + nonisolated private static func codexEntry(from record: CodexThreadRecord, codexHome: String) -> SessionEntry { let sandboxMode = record.sandboxJSON .flatMap { $0.data(using: .utf8) } .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } @@ -184,7 +184,7 @@ extension SessionIndexStore { gitBranch: record.gitBranch?.isEmpty == false ? record.gitBranch : nil, pullRequest: nil, modified: Date(timeIntervalSince1970: TimeInterval(record.updatedMs) / 1000.0), - fileURL: fileURL, + fileURL: fileURL, indexedCodexHome: codexHome, specifics: .codex( model: record.model?.isEmpty == false ? record.model : nil, approvalPolicy: record.approvalMode?.isEmpty == false ? record.approvalMode : nil, diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 3a09af379daf..d7a4cbf4787b 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -1922,7 +1922,7 @@ final class SessionIndexStore: ObservableObject { gitBranch: parsed.branch, pullRequest: nil, modified: mtime, - fileURL: url, + fileURL: url, indexedCodexHome: (root as NSString).deletingLastPathComponent, specifics: .codex( model: parsed.model, approvalPolicy: parsed.approvalPolicy, diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 4e25b332c469..2cb95d4b9ae6 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -7,148 +7,6 @@ import SQLite3 import SwiftUI import UniformTypeIdentifiers -@MainActor -enum SessionEntryResumeCoordinator { - @discardableResult - private static func launchInNewWorkspace( - _ launch: SessionEntryResumeLaunch, - tabManager: TabManager - ) -> Workspace? { - tabManager.addWorkspaceIfActive( - workingDirectory: launch.workingDirectory, - initialTerminalInput: launch.initialInput, - initialTerminalStartupRestoreAgent: launch.startupRestoreAgent - ) - } - - /// Returns the in-pane target for an indexed session, if one is currently - /// represented by a real surface in the tab manager. - /// - /// Keeping target discovery separate from the focus mutation lets the Vault - /// row expose an honest enabled/disabled state without focusing anything - /// while SwiftUI is rendering a context menu. - static func activeTarget( - for entry: SessionEntry, - tabManager: TabManager - ) -> (workspaceID: UUID, surfaceID: UUID)? { - // Prefer the tab manager's authoritative surface snapshots. This - // catches an open-but-idle session even while the process index is - // between refreshes. - for workspace in tabManager.tabs { - if let panel = workspace.restoredAgentSnapshotsByPanelId.first(where: { panelID, snapshot in - workspace.panels[panelID] != nil - && workspace.panelShellActivityStates[panelID] == .commandRunning - && snapshot.kind.rawValue == entry.agent.rawValue - && ManagedAgentSessionIdentity.sessionIDsMatch( - kind: entry.agent.rawValue, - lhs: snapshot.sessionId, - rhs: entry.sessionId - ) - }) { - return (workspace.id, panel.key) - } - } - - // Process-detected sessions can still be present in the live index - // before their snapshot has been projected into the tab manager. - guard let index = SharedLiveAgentIndex.shared.currentIndexSchedulingRefresh(), - let match = index.forkValidationEntries().first(where: { panelKey, observation in - observation.processLiveness == .running - && observation.snapshot.kind.rawValue == entry.agent.rawValue - && ManagedAgentSessionIdentity.sessionIDsMatch( - kind: entry.agent.rawValue, - lhs: observation.snapshot.sessionId, - rhs: entry.sessionId - ) - && tabManager.tabs.contains(where: { $0.id == panelKey.workspaceId }) - && tabManager.tabs.first(where: { $0.id == panelKey.workspaceId })?.panels[panelKey.panelId] != nil - }) else { - return nil - } - - return (match.0.workspaceId, match.0.panelId) - } - - /// Returns managed-session identities whose agent command is currently - /// running in a real pane. A shell-idle pane is intentionally excluded so - /// a failed restore or a quit cannot keep the Vault row green merely from - /// retaining its historical snapshot. - static func inPaneSessionKeys(tabManager: TabManager) -> Set { - var keys: Set = [] - for workspace in tabManager.tabs { - for (panelID, snapshot) in workspace.restoredAgentSnapshotsByPanelId - where workspace.panels[panelID] != nil - && workspace.panelShellActivityStates[panelID] == .commandRunning { - keys.insert( - VaultLiveSessionKeys.key( - kind: snapshot.kind.rawValue, - sessionID: snapshot.sessionId - ) - ) - } - } - return keys - } - - /// Opens an indexed session in a new split in the selected workspace. - /// - /// This is intentionally different from ``focusIfActive``: Open Session - /// is an explicit second launch, even when the same session is already - /// represented by a live pane. Focus Session is the action for reusing an - /// existing pane. - static func open(_ entry: SessionEntry, tabManager: TabManager) { - guard let launch = entry.resumeLaunch else { return } - - guard let workspace = tabManager.selectedWorkspace, - !workspace.isRemoteWorkspace, - !workspace.isRemoteTmuxMirror, - let paneId = workspace.bonsplitController.focusedPaneId - ?? workspace.bonsplitController.allPaneIds.first else { - // A remote workspace cannot safely execute a local Vault restore - // command. If there is no usable local pane, fall back to the - // same isolated-workspace launch used by Resume. - _ = launchInNewWorkspace(launch, tabManager: tabManager) - return - } - - // A zoomed pane has no room to represent the new split until it is - // restored to the normal layout. - workspace.clearSplitZoom() - if workspace.splitPaneWithNewTerminal( - targetPane: paneId, - orientation: .horizontal, - insertFirst: false, - workingDirectory: launch.workingDirectory, - initialInput: launch.initialInput, - startupRestoreAgent: launch.startupRestoreAgent - ) == nil { - // Keep the action useful if the selected workspace retires between - // menu presentation and invocation. - _ = launchInNewWorkspace(launch, tabManager: tabManager) - } - } - - /// Focuses the current surface for `entry` when the live agent index still - /// points at a real panel in this tab manager. - @discardableResult - static func focusIfActive(_ entry: SessionEntry, tabManager: TabManager) -> Bool { - guard let target = activeTarget(for: entry, tabManager: tabManager) else { - return false - } - tabManager.focusTab(target.workspaceID, surfaceId: target.surfaceID) - return true - } - - static func resume(_ entry: SessionEntry, tabManager: TabManager) { - guard let launch = entry.resumeLaunch else { return } - // Resume is deliberately workspace-scoped. It must remain predictable - // even when the selected workspace happens to share the session's cwd; - // Open Session is the separate action for a split in the current - // workspace. - _ = launchInNewWorkspace(launch, tabManager: tabManager) - } -} - struct SessionIndexView: View { @ObservedObject var store: SessionIndexStore @Environment(\.sessionDragRegistry) private var sessionDragRegistry diff --git a/Sources/TerminalController+VaultCommands.swift b/Sources/TerminalController+VaultCommands.swift index c6432d1cb89d..8d2bf17deb3b 100644 --- a/Sources/TerminalController+VaultCommands.swift +++ b/Sources/TerminalController+VaultCommands.swift @@ -210,13 +210,7 @@ extension TerminalController { let forked = entry.forkedEntry(newSessionID: newSessionID, fileURL: forkedURL, now: Date()) var opened = false if params["open"] as? Bool == true { - opened = v2MainSync(commandKey: "vault.fork") { - MainActor.assumeIsolated { - guard let tabManager = self.tabManager else { return false } - SessionEntryResumeCoordinator.resume(forked, tabManager: tabManager) - return true - } - } + opened = await resumeVaultFork(forked) } var payload: [String: Any] = [ "agent": forked.agent.rawValue, @@ -235,6 +229,12 @@ extension TerminalController { } } + @MainActor + private func resumeVaultFork(_ entry: SessionEntry) async -> Bool { + guard let tabManager else { return false } + return await SessionEntryResumeCoordinator(tabManager: tabManager).resume(entry) + } + // MARK: Shared helpers private nonisolated enum VaultEntryResolution { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 18dda3db65c0..ceba2f903822 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8760,7 +8760,7 @@ class TerminalController { switch ctx.webView.replayBrowserKeyboardEvent(event, action: action) { case .delivered: - var payload: [String: Any] = [ + let payload: [String: Any] = [ "workspace_id": ctx.workspaceId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ctx.workspaceId), "surface_id": ctx.surfaceId.uuidString, diff --git a/Sources/VaultCheckpointFork.swift b/Sources/VaultCheckpointFork.swift index 6f0927e14d80..0988f6b7864d 100644 --- a/Sources/VaultCheckpointFork.swift +++ b/Sources/VaultCheckpointFork.swift @@ -321,6 +321,7 @@ extension SessionEntry { pullRequest: nil, modified: now, fileURL: fileURL, + indexedCodexHome: indexedCodexHome, specifics: specifics, created: now, messageCount: nil diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index b5d49f53dfcb..06f87d8f1700 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -808,6 +808,7 @@ C0DE60C0000000000000A022 /* CMUXCLI+Coderouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE60C0000000000000A021 /* CMUXCLI+Coderouter.swift */; }; C0D3F1F00000000000000101 /* CMUXCLI+CodexFireAndForgetHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F1F00000000000000102 /* CMUXCLI+CodexFireAndForgetHooks.swift */; }; D96290030000000000000001 /* CMUXCLI+CodexResumeBindingVerification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D96290040000000000000001 /* CMUXCLI+CodexResumeBindingVerification.swift */; }; + D11973000000000000000011 /* CMUXCLI+CodexWriterRestore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000004 /* CMUXCLI+CodexWriterRestore.swift */; }; B90000D2A1B2C3D4E5F60719 /* CMUXCLI+CommandSuggestions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90000D1A1B2C3D4E5F60719 /* CMUXCLI+CommandSuggestions.swift */; }; CC0033E15A1B2C3D4E5F0001 /* CMUXCLI+Comments.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0033E15A1B2C3D4E5F0002 /* CMUXCLI+Comments.swift */; }; B9000050A1B2C3D4E5F60719 /* CMUXCLI+Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000051A1B2C3D4E5F60719 /* CMUXCLI+Config.swift */; }; @@ -845,6 +846,7 @@ 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 */; }; + D11973000000000000000019 /* CMUXCLI+RestoreLaunchPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000008 /* CMUXCLI+RestoreLaunchPayload.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 */; }; @@ -1113,6 +1115,8 @@ 7520C0DF0000000000000005 /* CodexTurnLedgerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7520C0DF0000000000000006 /* CodexTurnLedgerModels.swift */; }; 7520C0DF0000000000000007 /* CodexTurnLedgerPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7520C0DF0000000000000008 /* CodexTurnLedgerPersistence.swift */; }; 7520C0DF0000000000000009 /* CodexTurnLifecycleCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7520C0DF000000000000000A /* CodexTurnLifecycleCoordinator.swift */; }; + D11973000000000000000012 /* CodexWriterRestoreMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000001 /* CodexWriterRestoreMessage.swift */; }; + D11973000000000000000015 /* CodexWriterRestoreMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000001 /* CodexWriterRestoreMessage.swift */; }; C4041001000000000000001B /* CommandClickFileOpenRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4041001000000000000001A /* CommandClickFileOpenRouter.swift */; }; C0DEC0DE000000000000F302 /* CommandPaletteEmojiTitleSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEC0DE000000000000F301 /* CommandPaletteEmojiTitleSearchTests.swift */; }; C0DE86060000000000000001 /* CommandPaletteFocusRestoreCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE86060000000000000002 /* CommandPaletteFocusRestoreCoordinator.swift */; }; @@ -2222,6 +2226,11 @@ 469201000000000000000006 /* SessionDragSessionSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 469201000000000000000005 /* SessionDragSessionSource.swift */; }; 46920100000000000000000E /* SessionDragSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46920100000000000000000D /* SessionDragSource.swift */; }; 46920100000000000000000C /* SessionDragSourceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46920100000000000000000B /* SessionDragSourceView.swift */; }; + D11973000000000000000016 /* SessionEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000005 /* SessionEntry.swift */; }; + D11973000000000000000013 /* SessionEntryCodexHome.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000002 /* SessionEntryCodexHome.swift */; }; + D11973000000000000000014 /* SessionEntryResumeCoordinator+CodexWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000003 /* SessionEntryResumeCoordinator+CodexWriter.swift */; }; + D11973000000000000000017 /* SessionEntryResumeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000006 /* SessionEntryResumeCoordinator.swift */; }; + D11973000000000000000018 /* SessionEntryResumeCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11973000000000000000007 /* SessionEntryResumeCoordinatorTests.swift */; }; 992300019923000199230003 /* SessionEntryResumeLaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 992300019923000199230004 /* SessionEntryResumeLaunch.swift */; }; 992300019923000199230001 /* SessionEntryResumeLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 992300019923000199230002 /* SessionEntryResumeLaunchTests.swift */; }; D4476F030000000000000001 /* SessionIndexAgentIconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4476F030000000000000002 /* SessionIndexAgentIconImage.swift */; }; @@ -4143,6 +4152,7 @@ C0DE60C0000000000000A021 /* CMUXCLI+Coderouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Coderouter.swift"; sourceTree = ""; }; C0D3F1F00000000000000102 /* CMUXCLI+CodexFireAndForgetHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CodexFireAndForgetHooks.swift"; sourceTree = ""; }; D96290040000000000000001 /* CMUXCLI+CodexResumeBindingVerification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CodexResumeBindingVerification.swift"; sourceTree = ""; }; + D11973000000000000000004 /* CMUXCLI+CodexWriterRestore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CodexWriterRestore.swift"; sourceTree = ""; }; B90000D1A1B2C3D4E5F60719 /* CMUXCLI+CommandSuggestions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CommandSuggestions.swift"; sourceTree = ""; }; CC0033E15A1B2C3D4E5F0002 /* CMUXCLI+Comments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Comments.swift"; sourceTree = ""; }; B9000051A1B2C3D4E5F60719 /* CMUXCLI+Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Config.swift"; sourceTree = ""; }; @@ -4180,6 +4190,7 @@ 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 = ""; }; + D11973000000000000000008 /* CMUXCLI+RestoreLaunchPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+RestoreLaunchPayload.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 = ""; }; @@ -4380,6 +4391,7 @@ 7520C0DF0000000000000006 /* CodexTurnLedgerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexTurnLedgerModels.swift; sourceTree = ""; }; 7520C0DF0000000000000008 /* CodexTurnLedgerPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexTurnLedgerPersistence.swift; sourceTree = ""; }; 7520C0DF000000000000000A /* CodexTurnLifecycleCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexTurnLifecycleCoordinator.swift; sourceTree = ""; }; + D11973000000000000000001 /* CodexWriterRestoreMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexWriterRestoreMessage.swift; sourceTree = ""; }; C4041001000000000000001A /* CommandClickFileOpenRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandClickFileOpenRouter.swift; sourceTree = ""; }; C0DEC0DE000000000000F301 /* CommandPaletteEmojiTitleSearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteEmojiTitleSearchTests.swift; sourceTree = ""; }; C0DE86060000000000000002 /* CommandPaletteFocusRestoreCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteFocusRestoreCoordinator.swift; sourceTree = ""; }; @@ -5481,6 +5493,11 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 469201000000000000000005 /* SessionDragSessionSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDragSessionSource.swift; sourceTree = ""; }; 46920100000000000000000D /* SessionDragSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDragSource.swift; sourceTree = ""; }; 46920100000000000000000B /* SessionDragSourceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDragSourceView.swift; sourceTree = ""; }; + D11973000000000000000005 /* SessionEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntry.swift; sourceTree = ""; }; + D11973000000000000000002 /* SessionEntryCodexHome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntryCodexHome.swift; sourceTree = ""; }; + D11973000000000000000003 /* SessionEntryResumeCoordinator+CodexWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionEntryResumeCoordinator+CodexWriter.swift"; sourceTree = ""; }; + D11973000000000000000006 /* SessionEntryResumeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntryResumeCoordinator.swift; sourceTree = ""; }; + D11973000000000000000007 /* SessionEntryResumeCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntryResumeCoordinatorTests.swift; sourceTree = ""; }; 992300019923000199230004 /* SessionEntryResumeLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntryResumeLaunch.swift; sourceTree = ""; }; 992300019923000199230002 /* SessionEntryResumeLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEntryResumeLaunchTests.swift; sourceTree = ""; }; D4476F030000000000000002 /* SessionIndexAgentIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexAgentIconImage.swift; sourceTree = ""; }; @@ -7809,6 +7826,10 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 9090F0029090F0029090F002 /* AgentHibernationResumePreparation.swift */, F0ACC0E0000000000000002 /* CachedAgentProcessIdentityValidator.swift */, 992300019923000199230004 /* SessionEntryResumeLaunch.swift */, + D11973000000000000000002 /* SessionEntryCodexHome.swift */, + D11973000000000000000005 /* SessionEntry.swift */, + D11973000000000000000006 /* SessionEntryResumeCoordinator.swift */, + D11973000000000000000003 /* SessionEntryResumeCoordinator+CodexWriter.swift */, B3575000000000000000000B /* SessionIndexModels.swift */, 9758E0069758E0069758E006 /* SessionSnapshotCodingTrust.swift */, 9758D0039758D0039758D003 /* CmuxVaultHookSessionStore.swift */, @@ -8768,6 +8789,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A2C22D3F893E4B2D8FB3B91F /* VaultAllSessionsBar.swift */, 189ED88E2958436BAE17D5E4 /* VaultSessionSearchQuery.swift */, 5B9D1EE40CA94CD8BEDDDD98 /* VaultSessionLiveStatus.swift */, + D11973000000000000000001 /* CodexWriterRestoreMessage.swift */, D6F100000000000000000001 /* SessionIndexStatusIndicator.swift */, D6F100000000000000000004 /* SessionIndexStatusSnapshot.swift */, D6F100000000000000000006 /* SessionIndexStatusIndicatorModel.swift */, @@ -9016,6 +9038,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 4CF59318F1D5B2195AC77C28 /* CMUXCLI+ClaudePushNotificationHook.swift */, C6711B050000000000000001 /* CMUXCLI+AgentHookRestoreEvidence.swift */, D96290040000000000000001 /* CMUXCLI+CodexResumeBindingVerification.swift */, + D11973000000000000000004 /* CMUXCLI+CodexWriterRestore.swift */, + D11973000000000000000008 /* CMUXCLI+RestoreLaunchPayload.swift */, C6209A020000000000000001 /* CodexRestoreValidationResult.swift */, 5257257034CA4729B1211166 /* CMUXCLI+AutoNaming.swift */, 5257257034CA4729B121116A /* CMUXCLI+AutoNamingDispatch.swift */, @@ -9168,6 +9192,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 8837A0020000000000000002 /* AgentResumeReturnShellStartupTests.swift */, 9200B0019200B0019200B002 /* ResumeLauncherCwdConsistencyTests.swift */, 992300019923000199230002 /* SessionEntryResumeLaunchTests.swift */, + D11973000000000000000007 /* SessionEntryResumeCoordinatorTests.swift */, 992300019923000199230005 /* VaultRestoreRelaunchPersistenceTests.swift */, 99230001992300019923000F /* VaultQueuedRestoreIdentityTests.swift */, 99230001992300019923000D /* TerminalStartupRestoreFailureTests.swift */, @@ -11091,8 +11116,9 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A9E02000000000000000000E /* CodexAppServerSession.swift in Sources */, C8711C000000000000000002 /* CodexRolloutIdentity.swift in Sources */, C8711D000000000000000002 /* CodexRolloutIdentityResolver.swift in Sources */, - A10240030000000000000001 /* CodexTeamsAppServerProcess.swift in Sources */, - A10240100000000000000001 /* CodexTeamsPOSIXSupport.swift in Sources */, + A10240030000000000000001 /* CodexTeamsAppServerProcess.swift in Sources */, + A10240100000000000000001 /* CodexTeamsPOSIXSupport.swift in Sources */, + D11973000000000000000012 /* CodexWriterRestoreMessage.swift in Sources */, C4041001000000000000001B /* CommandClickFileOpenRouter.swift in Sources */, C0DE86060000000000000001 /* CommandPaletteFocusRestoreCoordinator.swift in Sources */, C0DEFF200000000000000001 /* CommandPaletteOverlay.swift in Sources */, @@ -11849,6 +11875,10 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 469201000000000000000006 /* SessionDragSessionSource.swift in Sources */, 46920100000000000000000E /* SessionDragSource.swift in Sources */, 46920100000000000000000C /* SessionDragSourceView.swift in Sources */, + D11973000000000000000016 /* SessionEntry.swift in Sources */, + D11973000000000000000013 /* SessionEntryCodexHome.swift in Sources */, + D11973000000000000000014 /* SessionEntryResumeCoordinator+CodexWriter.swift in Sources */, + D11973000000000000000017 /* SessionEntryResumeCoordinator.swift in Sources */, 992300019923000199230003 /* SessionEntryResumeLaunch.swift in Sources */, D4476F030000000000000001 /* SessionIndexAgentIconImage.swift in Sources */, 85000000000000000000002A /* SessionIndexEntryProjection.swift in Sources */, @@ -12679,6 +12709,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C0DE60C0000000000000A022 /* CMUXCLI+Coderouter.swift in Sources */, C0D3F1F00000000000000101 /* CMUXCLI+CodexFireAndForgetHooks.swift in Sources */, D96290030000000000000001 /* CMUXCLI+CodexResumeBindingVerification.swift in Sources */, + D11973000000000000000011 /* CMUXCLI+CodexWriterRestore.swift in Sources */, B90000D2A1B2C3D4E5F60719 /* CMUXCLI+CommandSuggestions.swift in Sources */, CC0033E15A1B2C3D4E5F0001 /* CMUXCLI+Comments.swift in Sources */, B9000050A1B2C3D4E5F60719 /* CMUXCLI+Config.swift in Sources */, @@ -12716,6 +12747,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 925800000000000000000002 /* CMUXCLI+Restore.swift in Sources */, 92580000000000000000000B /* CMUXCLI+RestoreExecution.swift in Sources */, 925800000000000000000009 /* CMUXCLI+RestoreFailureReporting.swift in Sources */, + D11973000000000000000019 /* CMUXCLI+RestoreLaunchPayload.swift in Sources */, 925800000000000000000003 /* CMUXCLI+RestorePreflight.swift in Sources */, 925800000000000000000007 /* CMUXCLI+RestoreRecord.swift in Sources */, 925800000000000000000005 /* CMUXCLI+RestoreSelector.swift in Sources */, @@ -12774,6 +12806,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 7520C0DF0000000000000005 /* CodexTurnLedgerModels.swift in Sources */, 7520C0DF0000000000000007 /* CodexTurnLedgerPersistence.swift in Sources */, 7520C0DF0000000000000009 /* CodexTurnLifecycleCoordinator.swift in Sources */, + D11973000000000000000015 /* CodexWriterRestoreMessage.swift in Sources */, FEEDC1A50000000000000001 /* FeedEventClassifier.swift in Sources */, C51A73B20000000000000002 /* IOSScreenshotCommandResultBox.swift in Sources */, A5FB1308 /* JSONCObjectEditor+Remove.swift in Sources */, @@ -13448,6 +13481,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C0DE1A070000000000000001 /* SavedLayoutStoreTests.swift in Sources */, 3865A0043865A0043865A004 /* SearchIndexTests.swift in Sources */, C71510010000000000000001 /* SessionContentWidthSettingsFileStoreTests.swift in Sources */, + D11973000000000000000018 /* SessionEntryResumeCoordinatorTests.swift in Sources */, 992300019923000199230001 /* SessionEntryResumeLaunchTests.swift in Sources */, 850000000000000000000004 /* SessionIndexJSONLReaderTests.swift in Sources */, 850000000000000000000006 /* SessionIndexSnapshotLoaderTests.swift in Sources */, diff --git a/cmuxTests/SessionEntryResumeCoordinatorTests.swift b/cmuxTests/SessionEntryResumeCoordinatorTests.swift new file mode 100644 index 000000000000..0375d82144d3 --- /dev/null +++ b/cmuxTests/SessionEntryResumeCoordinatorTests.swift @@ -0,0 +1,216 @@ +import CMUXAgentLaunch +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) +struct SessionEntryResumeCoordinatorTests { + @Test("Vault resume always creates a new workspace") + func coordinatorResumesInNewWorkspace() async throws { + let home = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let matchingDirectory = "/tmp/vault-coordinator-match" + let manager = TabManager( + initialWorkingDirectory: matchingDirectory, + autoWelcomeIfNeeded: false + ) + defer { manager.tabs.forEach { $0.teardownAllPanels() } } + let originalWorkspace = try #require(manager.selectedWorkspace) + + let matchingEntry = SessionEntry( + id: "codex:matching-session", + agent: .codex, + sessionId: "01a06e0d-8793-7f33-b044-2b49a10c2261", + title: "Matching session", + cwd: matchingDirectory, + gitBranch: nil, + pullRequest: nil, + modified: Date(timeIntervalSince1970: 1_800_000_005), + fileURL: nil, indexedCodexHome: home.path, + specifics: .codex( + model: nil, + approvalPolicy: nil, + sandboxMode: nil, + effort: nil + ) + ) + let matchingLaunch = try #require(matchingEntry.resumeLaunch) + + await SessionEntryResumeCoordinator(tabManager: manager).resume(matchingEntry) + + #expect(manager.tabs.count == 2) + let matchingWorkspace = try #require(manager.selectedWorkspace) + #expect(matchingWorkspace !== originalWorkspace) + #expect(matchingWorkspace.currentDirectory == matchingDirectory) + let matchingPanelID = try #require(matchingWorkspace.focusedPanelId) + #expect( + matchingWorkspace.restoredAgentSnapshotsByPanelId[matchingPanelID]?.sessionId + == "01a06e0d-8793-7f33-b044-2b49a10c2261" + ) + #expect( + matchingWorkspace.restoredResumeSessionWorkingDirectoriesByPanelId[matchingPanelID] + == matchingDirectory + ) + #expect( + matchingWorkspace.terminalPanel(for: matchingPanelID)? + .surface.debugInitialInputForTesting() == matchingLaunch.initialInput + ) + + let differentDirectory = "/tmp/vault-coordinator-new-workspace" + let differentEntry = SessionEntry( + id: "codex:different-session", + agent: .codex, + sessionId: "01a06e0d-8793-7f33-b044-2b49a10c2262", + title: "Different session", + cwd: differentDirectory, + gitBranch: nil, + pullRequest: nil, + modified: Date(timeIntervalSince1970: 1_800_000_006), + fileURL: nil, indexedCodexHome: home.path, + specifics: .codex( + model: nil, + approvalPolicy: nil, + sandboxMode: nil, + effort: nil + ) + ) + let differentLaunch = try #require(differentEntry.resumeLaunch) + + await SessionEntryResumeCoordinator(tabManager: manager).resume(differentEntry) + + #expect(manager.tabs.count == 3) + let createdWorkspace = try #require(manager.selectedWorkspace) + #expect(createdWorkspace !== originalWorkspace) + #expect(createdWorkspace !== matchingWorkspace) + #expect(createdWorkspace.currentDirectory == differentDirectory) + let createdPanelID = try #require(createdWorkspace.focusedPanelId) + #expect( + createdWorkspace.restoredAgentSnapshotsByPanelId[createdPanelID]?.sessionId + == "01a06e0d-8793-7f33-b044-2b49a10c2262" + ) + #expect( + createdWorkspace.restoredResumeSessionWorkingDirectoriesByPanelId[createdPanelID] + == differentDirectory + ) + #expect( + createdWorkspace.terminalPanel(for: createdPanelID)? + .surface.debugInitialInputForTesting() == differentLaunch.initialInput + ) + } + + @Test("Open Session ignores stale Codex snapshots when the lock is available") + func coordinatorOpensActiveSessionInCurrentWorkspaceSplit() async throws { + let home = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let workingDirectory = "/tmp/vault-coordinator-open" + let manager = TabManager( + initialWorkingDirectory: workingDirectory, + autoWelcomeIfNeeded: false + ) + defer { manager.tabs.forEach { $0.teardownAllPanels() } } + let workspace = try #require(manager.selectedWorkspace) + let initialPaneID = try #require(workspace.bonsplitController.focusedPaneId) + + let entry = SessionEntry( + id: "codex:already-active-session", + agent: .codex, + sessionId: "01a06e0d-8793-7f33-b044-2b49a10c2263", + title: "Already active", + cwd: workingDirectory, + gitBranch: nil, + pullRequest: nil, + modified: Date(timeIntervalSince1970: 1_800_000_007), + fileURL: nil, indexedCodexHome: home.path, + specifics: .codex( + model: nil, + approvalPolicy: nil, + sandboxMode: nil, + effort: nil + ) + ) + let launch = try #require(entry.resumeLaunch) + let snapshot = try #require(launch.startupRestoreAgent) + + let existingPanel = try #require(workspace.newTerminalSurface( + inPane: initialPaneID, + focus: true, + workingDirectory: launch.workingDirectory, + initialInput: launch.initialInput, + startupRestoreAgent: snapshot + )) + #expect( + workspace.restoredAgentSnapshotsByPanelId[existingPanel.id]?.sessionId + == entry.sessionId + ) + let paneCountBefore = workspace.bonsplitController.allPaneIds.count + let panelCountBefore = workspace.panels.count + + await SessionEntryResumeCoordinator(tabManager: manager).open(entry) + + #expect(manager.tabs.count == 1) + #expect(manager.selectedWorkspace === workspace) + #expect(workspace.bonsplitController.allPaneIds.count == paneCountBefore + 1) + #expect(workspace.panels.count == panelCountBefore + 1) + let openedPanelID = try #require(workspace.focusedPanelId) + #expect(openedPanelID != existingPanel.id) + #expect( + workspace.restoredAgentSnapshotsByPanelId[openedPanelID]?.sessionId + == entry.sessionId + ) + #expect( + workspace.restoredResumeSessionWorkingDirectoriesByPanelId[openedPanelID] + == workingDirectory + ) + #expect( + workspace.terminalPanel(for: openedPanelID)? + .surface.debugInitialInputForTesting() == launch.initialInput + ) + } + + @Test("Non-Codex Vault active-session keys follow foreground shell activity") + func inPaneSessionKeysDropAfterAgentReturnsToShell() throws { + let manager = TabManager( + initialWorkingDirectory: "/tmp/vault-active-session", + autoWelcomeIfNeeded: false + ) + defer { manager.tabs.forEach { $0.teardownAllPanels() } } + let workspace = try #require(manager.selectedWorkspace) + let paneID = try #require(workspace.bonsplitController.focusedPaneId) + let entry = SessionEntry( + id: "claude:active-session", + agent: .claude, + sessionId: "active-session", + title: "Active session", + cwd: "/tmp/vault-active-session", + gitBranch: nil, + pullRequest: nil, + modified: Date(timeIntervalSince1970: 1_800_000_008), + fileURL: nil, + specifics: .claude(model: nil, permissionMode: nil, configDirectoryForResume: nil) + ) + let launch = try #require(entry.resumeLaunch) + let snapshot = try #require(launch.startupRestoreAgent) + let panel = try #require(workspace.newTerminalSurface( + inPane: paneID, + focus: true, + workingDirectory: launch.workingDirectory, + initialInput: launch.initialInput, + startupRestoreAgent: snapshot + )) + let key = VaultLiveSessionKeys.key(for: entry) + + workspace.updatePanelShellActivityState(panelId: panel.id, state: .commandRunning) + #expect(SessionEntryResumeCoordinator(tabManager: manager).inPaneSessionKeys().contains(key)) + + workspace.updatePanelShellActivityState(panelId: panel.id, state: .promptIdle) + #expect(!SessionEntryResumeCoordinator(tabManager: manager).inPaneSessionKeys().contains(key)) + } +} diff --git a/cmuxTests/SessionEntryResumeLaunchTests.swift b/cmuxTests/SessionEntryResumeLaunchTests.swift index e44268570ace..50d555c7290b 100644 --- a/cmuxTests/SessionEntryResumeLaunchTests.swift +++ b/cmuxTests/SessionEntryResumeLaunchTests.swift @@ -54,6 +54,45 @@ struct SessionEntryResumeLaunchTests { #expect(arguments.contains("model_reasoning_effort=high")) } + @Test("Vault Codex restore keeps the transcript's effective Codex home") + func vaultCodexRestorePreservesEffectiveCodexHome() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-vault-codex-home-\(UUID().uuidString)", isDirectory: true) + let codexHome = root.appendingPathComponent("codex-account", isDirectory: true) + let transcript = codexHome + .appendingPathComponent("sessions/2026/09/04/rollout-session.jsonl", isDirectory: false) + try FileManager.default.createDirectory( + at: transcript.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: root) } + + let entry = SessionEntry( + id: "codex:\(transcript.path)", + agent: .codex, + sessionId: "vault-session-home", + title: "Account-scoped Codex session", + cwd: root.path, + gitBranch: nil, + pullRequest: nil, + modified: Date(timeIntervalSince1970: 1_800_000_000), + fileURL: transcript, + specifics: .codex( + model: "gpt-5.5", + approvalPolicy: nil, + sandboxMode: nil, + effort: nil + ) + ) + + let launch = try #require(entry.resumeLaunch) + let snapshot = try #require(launch.startupRestoreAgent) + #expect( + snapshot.launchCommand?.environment?["CODEX_HOME"] == codexHome.path, + "Vault restore must probe and resume the account that owns the transcript" + ) + } + @Test("Registered Vault agents use structured restore argv") func registeredAgentUsesStructuredRestore() throws { let registration = CmuxVaultAgentRegistration( @@ -399,205 +438,4 @@ struct SessionEntryResumeLaunchTests { #expect(inheritedSplit.requestedWorkingDirectory == workingDirectory) } - @Test("Vault resume always creates a new workspace") - func coordinatorResumesInNewWorkspace() throws { - let matchingDirectory = "/tmp/vault-coordinator-match" - let manager = TabManager( - initialWorkingDirectory: matchingDirectory, - autoWelcomeIfNeeded: false - ) - defer { manager.tabs.forEach { $0.teardownAllPanels() } } - let originalWorkspace = try #require(manager.selectedWorkspace) - - let matchingEntry = SessionEntry( - id: "codex:matching-session", - agent: .codex, - sessionId: "matching-session", - title: "Matching session", - cwd: matchingDirectory, - gitBranch: nil, - pullRequest: nil, - modified: Date(timeIntervalSince1970: 1_800_000_005), - fileURL: nil, - specifics: .codex( - model: nil, - approvalPolicy: nil, - sandboxMode: nil, - effort: nil - ) - ) - let matchingLaunch = try #require(matchingEntry.resumeLaunch) - - SessionEntryResumeCoordinator.resume(matchingEntry, tabManager: manager) - - #expect(manager.tabs.count == 2) - let matchingWorkspace = try #require(manager.selectedWorkspace) - #expect(matchingWorkspace !== originalWorkspace) - #expect(matchingWorkspace.currentDirectory == matchingDirectory) - let matchingPanelID = try #require(matchingWorkspace.focusedPanelId) - #expect( - matchingWorkspace.restoredAgentSnapshotsByPanelId[matchingPanelID]?.sessionId - == "matching-session" - ) - #expect( - matchingWorkspace.restoredResumeSessionWorkingDirectoriesByPanelId[matchingPanelID] - == matchingDirectory - ) - #expect( - matchingWorkspace.terminalPanel(for: matchingPanelID)? - .surface.debugInitialInputForTesting() == matchingLaunch.initialInput - ) - - let differentDirectory = "/tmp/vault-coordinator-new-workspace" - let differentEntry = SessionEntry( - id: "codex:different-session", - agent: .codex, - sessionId: "different-session", - title: "Different session", - cwd: differentDirectory, - gitBranch: nil, - pullRequest: nil, - modified: Date(timeIntervalSince1970: 1_800_000_006), - fileURL: nil, - specifics: .codex( - model: nil, - approvalPolicy: nil, - sandboxMode: nil, - effort: nil - ) - ) - let differentLaunch = try #require(differentEntry.resumeLaunch) - - SessionEntryResumeCoordinator.resume(differentEntry, tabManager: manager) - - #expect(manager.tabs.count == 3) - let createdWorkspace = try #require(manager.selectedWorkspace) - #expect(createdWorkspace !== originalWorkspace) - #expect(createdWorkspace !== matchingWorkspace) - #expect(createdWorkspace.currentDirectory == differentDirectory) - let createdPanelID = try #require(createdWorkspace.focusedPanelId) - #expect( - createdWorkspace.restoredAgentSnapshotsByPanelId[createdPanelID]?.sessionId - == "different-session" - ) - #expect( - createdWorkspace.restoredResumeSessionWorkingDirectoriesByPanelId[createdPanelID] - == differentDirectory - ) - #expect( - createdWorkspace.terminalPanel(for: createdPanelID)? - .surface.debugInitialInputForTesting() == differentLaunch.initialInput - ) - } - - @Test("Open Session creates a split even when the session is already active") - func coordinatorOpensActiveSessionInCurrentWorkspaceSplit() throws { - let workingDirectory = "/tmp/vault-coordinator-open" - let manager = TabManager( - initialWorkingDirectory: workingDirectory, - autoWelcomeIfNeeded: false - ) - defer { manager.tabs.forEach { $0.teardownAllPanels() } } - let workspace = try #require(manager.selectedWorkspace) - let initialPaneID = try #require(workspace.bonsplitController.focusedPaneId) - - let entry = SessionEntry( - id: "codex:already-active-session", - agent: .codex, - sessionId: "already-active-session", - title: "Already active", - cwd: workingDirectory, - gitBranch: nil, - pullRequest: nil, - modified: Date(timeIntervalSince1970: 1_800_000_007), - fileURL: nil, - specifics: .codex( - model: nil, - approvalPolicy: nil, - sandboxMode: nil, - effort: nil - ) - ) - let launch = try #require(entry.resumeLaunch) - let snapshot = try #require(launch.startupRestoreAgent) - - let existingPanel = try #require(workspace.newTerminalSurface( - inPane: initialPaneID, - focus: true, - workingDirectory: launch.workingDirectory, - initialInput: launch.initialInput, - startupRestoreAgent: snapshot - )) - #expect( - workspace.restoredAgentSnapshotsByPanelId[existingPanel.id]?.sessionId - == entry.sessionId - ) - let paneCountBefore = workspace.bonsplitController.allPaneIds.count - let panelCountBefore = workspace.panels.count - - SessionEntryResumeCoordinator.open(entry, tabManager: manager) - - #expect(manager.tabs.count == 1) - #expect(manager.selectedWorkspace === workspace) - #expect(workspace.bonsplitController.allPaneIds.count == paneCountBefore + 1) - #expect(workspace.panels.count == panelCountBefore + 1) - let openedPanelID = try #require(workspace.focusedPanelId) - #expect(openedPanelID != existingPanel.id) - #expect( - workspace.restoredAgentSnapshotsByPanelId[openedPanelID]?.sessionId - == entry.sessionId - ) - #expect( - workspace.restoredResumeSessionWorkingDirectoriesByPanelId[openedPanelID] - == workingDirectory - ) - #expect( - workspace.terminalPanel(for: openedPanelID)? - .surface.debugInitialInputForTesting() == launch.initialInput - ) - } - - @Test("Vault active-session keys follow foreground shell activity") - func inPaneSessionKeysDropAfterAgentReturnsToShell() throws { - let manager = TabManager( - initialWorkingDirectory: "/tmp/vault-active-session", - autoWelcomeIfNeeded: false - ) - defer { manager.tabs.forEach { $0.teardownAllPanels() } } - let workspace = try #require(manager.selectedWorkspace) - let paneID = try #require(workspace.bonsplitController.focusedPaneId) - let entry = SessionEntry( - id: "codex:active-session", - agent: .codex, - sessionId: "active-session", - title: "Active session", - cwd: "/tmp/vault-active-session", - gitBranch: nil, - pullRequest: nil, - modified: Date(timeIntervalSince1970: 1_800_000_008), - fileURL: nil, - specifics: .codex( - model: nil, - approvalPolicy: nil, - sandboxMode: nil, - effort: nil - ) - ) - let launch = try #require(entry.resumeLaunch) - let snapshot = try #require(launch.startupRestoreAgent) - let panel = try #require(workspace.newTerminalSurface( - inPane: paneID, - focus: true, - workingDirectory: launch.workingDirectory, - initialInput: launch.initialInput, - startupRestoreAgent: snapshot - )) - let key = VaultLiveSessionKeys.key(for: entry) - - workspace.updatePanelShellActivityState(panelId: panel.id, state: .commandRunning) - #expect(SessionEntryResumeCoordinator.inPaneSessionKeys(tabManager: manager).contains(key)) - - workspace.updatePanelShellActivityState(panelId: panel.id, state: .promptIdle) - #expect(!SessionEntryResumeCoordinator.inPaneSessionKeys(tabManager: manager).contains(key)) - } } diff --git a/cmuxTests/SurfaceCatalogTests.swift b/cmuxTests/SurfaceCatalogTests.swift index c1e0404a9d78..1c72bb5db688 100644 --- a/cmuxTests/SurfaceCatalogTests.swift +++ b/cmuxTests/SurfaceCatalogTests.swift @@ -1176,268 +1176,4 @@ struct SurfaceCatalogTests { #expect(provider.closedRemoteWorkspaces == ["ws_empty"]) } - @Test func `Cloud workspace rename updates every projection and rejects stale snapshots`() throws { - let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() - let provider = FakeProvider(machine: machine) - catalog.register(provider) - - let original = SurfaceRemoteWorkspace(id: "ws_main", name: "main", index: 0, focused: true) - let other = SurfaceRemoteWorkspace(id: "ws_other", name: "other", index: 1, focused: false) - var terminal = SurfaceResource( - id: SurfaceResourceID(machine: machine, kind: .terminal, key: "term_main"), - title: "shell", - detail: nil, - lifecycle: .running, - agent: nil, - remoteWorkspace: original, - port: nil, - url: nil - ) - terminal.remoteViews = [SurfaceRemoteView(tabID: "tab_main", workspace: original)] - var unrelated = SurfaceResource( - id: SurfaceResourceID(machine: machine, kind: .terminal, key: "term_other"), - title: "other", - detail: nil, - lifecycle: .running, - agent: nil, - remoteWorkspace: other, - port: nil, - url: nil - ) - unrelated.remoteViews = [SurfaceRemoteView(tabID: "tab_other", workspace: other)] - let initialInfo = SurfaceMachineInfo( - id: machine, - name: "vivid-newt", - status: "running", - image: nil, - hasDesktop: false, - memoryMb: nil, - diskMb: nil, - linkState: .connected, - linkError: nil, - cpuPercent: nil, - memoryUsedMb: nil, - diskUsedMb: nil, - remoteWorkspaces: [original, other] - ) - let generation = "daemon-generation" - #expect(catalog.replaceCloudResources( - [terminal, unrelated], - on: machine, - info: initialInfo, - cursor: CloudVMCursor(generation: generation, revision: 10) - )) - - let token = try catalog.beginCloudWorkspaceRename( - machine: machine, - workspaceID: original.id, - name: "Renamed" - ) - let optimistic = catalog.snapshot - #expect(optimistic.machines.first?.remoteWorkspaces?.first { $0.id == original.id }?.name == "Renamed") - #expect(optimistic.resources.first { $0.id == terminal.id }?.remoteWorkspace?.name == "Renamed") - #expect(optimistic.resources.first { $0.id == terminal.id }?.remoteViews?.first?.workspace.name == "Renamed") - #expect(optimistic.resources.first { $0.id == unrelated.id }?.remoteWorkspace?.name == "other") - - let staleInfo = initialInfo - var staleTerminal = terminal - staleTerminal.remoteWorkspace = original - staleTerminal.remoteViews = [SurfaceRemoteView(tabID: "tab_main", workspace: original)] - #expect(!catalog.replaceCloudResources( - [staleTerminal, unrelated], - on: machine, - info: staleInfo, - cursor: CloudVMCursor(generation: generation, revision: 9) - )) - #expect(catalog.snapshot.resources.first { $0.id == terminal.id }?.remoteWorkspace?.name == "Renamed") - - catalog.commitCloudWorkspaceRename( - token, - receipt: CloudVMCursor(generation: generation, revision: 11) - ) - var confirmedInfo = initialInfo - let confirmed = SurfaceRemoteWorkspace(id: original.id, name: "Renamed", index: 0, focused: true) - confirmedInfo.remoteWorkspaces = [confirmed, other] - var confirmedTerminal = terminal - confirmedTerminal.remoteWorkspace = confirmed - confirmedTerminal.remoteViews = [SurfaceRemoteView(tabID: "tab_main", workspace: confirmed)] - #expect(catalog.replaceCloudResources( - [confirmedTerminal, unrelated], - on: machine, - info: confirmedInfo, - cursor: CloudVMCursor(generation: generation, revision: 11) - )) - #expect(catalog.pendingCloudWorkspaceRenameName(machine: machine, workspaceID: original.id) == nil) - #expect(!catalog.replaceCloudResources( - [staleTerminal, unrelated], - on: machine, - info: staleInfo, - cursor: CloudVMCursor(generation: generation, revision: 10) - )) - #expect(catalog.snapshot.resources.first { $0.id == terminal.id }?.remoteWorkspace?.name == "Renamed") - // An equal-cursor payload must also be identical; a delayed response - // with the old name cannot overwrite the confirmed rename. - #expect(!catalog.replaceCloudResources( - [staleTerminal, unrelated], - on: machine, - info: staleInfo, - cursor: CloudVMCursor(generation: generation, revision: 11) - )) - #expect(catalog.snapshot.resources.first { $0.id == terminal.id }?.remoteWorkspace?.name == "Renamed") - } - - @Test func `A cursorless machine update cannot restore a confirmed cloud workspace name`() throws { - let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() - let provider = FakeProvider(machine: machine) - catalog.register(provider) - let before = SurfaceRemoteWorkspace(id: "ws_main", name: "before", index: 0, focused: true) - let after = SurfaceRemoteWorkspace(id: before.id, name: "after", index: 0, focused: true) - var beforeResource = terminal(machine, "term_main") - beforeResource.remoteWorkspace = before - var afterResource = beforeResource - afterResource.remoteWorkspace = after - let beforeInfo = SurfaceMachineInfo( - id: machine, - name: machine.rawValue, - status: "running", - image: nil, - hasDesktop: false, - memoryMb: nil, - diskMb: nil, - linkState: .connected, - linkError: nil, - cpuPercent: nil, - memoryUsedMb: nil, - diskUsedMb: nil, - remoteWorkspaces: [before] - ) - var afterInfo = beforeInfo - afterInfo.remoteWorkspaces = [after] - #expect(catalog.replaceCloudResources( - [beforeResource], - on: machine, - info: beforeInfo, - cursor: CloudVMCursor(generation: "g", revision: 1) - )) - #expect(catalog.replaceCloudResources( - [afterResource], - on: machine, - info: afterInfo, - cursor: CloudVMCursor(generation: "g", revision: 2) - )) - - // A provider summary/status write has no cursor and may still carry its - // pre-rename cached workspace value. It must not overwrite the accepted graph. - catalog.updateMachine(beforeInfo, from: provider) - #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first?.name == "after") - } - - @Test func `A stale equal-cursor snapshot after a rename receipt cannot poison reconciliation`() throws { - let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() - catalog.register(FakeProvider(machine: machine)) - let before = SurfaceRemoteWorkspace(id: "ws_main", name: "before", index: 0, focused: true) - let after = SurfaceRemoteWorkspace(id: before.id, name: "after", index: 0, focused: true) - var beforeResource = terminal(machine, "term_main") - beforeResource.remoteWorkspace = before - var afterResource = beforeResource - afterResource.remoteWorkspace = after - let beforeInfo = SurfaceMachineInfo( - id: machine, - name: machine.rawValue, - status: "running", - image: nil, - hasDesktop: false, - memoryMb: nil, - diskMb: nil, - linkState: .connected, - linkError: nil, - cpuPercent: nil, - memoryUsedMb: nil, - remoteWorkspaces: [before] - ) - var afterInfo = beforeInfo - afterInfo.remoteWorkspaces = [after] - #expect(catalog.replaceCloudResources( - [beforeResource], - on: machine, - info: beforeInfo, - cursor: CloudVMCursor(generation: "g", revision: 1) - )) - let token = try catalog.beginCloudWorkspaceRename( - machine: machine, - workspaceID: before.id, - name: "after" - ) - catalog.commitCloudWorkspaceRename( - token, - receipt: CloudVMCursor(generation: "g", revision: 2) - ) - - // The first read at the receipt cursor is stale, but the optimistic overlay - // keeps the UI correct. A later canonical read at that same cursor must still - // be accepted and retire the intent. - #expect(!catalog.replaceCloudResources( - [beforeResource], - on: machine, - info: beforeInfo, - cursor: CloudVMCursor(generation: "g", revision: 2) - )) - #expect(catalog.replaceCloudResources( - [afterResource], - on: machine, - info: afterInfo, - cursor: CloudVMCursor(generation: "g", revision: 2) - )) - #expect(catalog.pendingCloudWorkspaceRenameName(machine: machine, workspaceID: before.id) == nil) - #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first?.name == "after") - } - - @Test func `An older cloud rename completion cannot roll back a newer intent`() throws { - let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() - catalog.register(FakeProvider(machine: machine)) - let workspace = SurfaceRemoteWorkspace(id: "ws_main", name: "main", index: 0, focused: true) - let info = SurfaceMachineInfo( - id: machine, - name: "vivid-newt", - status: "running", - image: nil, - hasDesktop: false, - memoryMb: nil, - diskMb: nil, - linkState: .connected, - linkError: nil, - cpuPercent: nil, - memoryUsedMb: nil, - diskUsedMb: nil, - remoteWorkspaces: [workspace] - ) - let resource = SurfaceResource( - id: SurfaceResourceID(machine: machine, kind: .terminal, key: "term"), - title: "shell", - detail: nil, - lifecycle: .running, - agent: nil, - remoteWorkspace: workspace, - port: nil, - url: nil - ) - #expect(catalog.replaceCloudResources( - [resource], - on: machine, - info: info, - cursor: CloudVMCursor(generation: "g", revision: 1) - )) - let first = try catalog.beginCloudWorkspaceRename(machine: machine, workspaceID: workspace.id, name: "first") - let second = try catalog.beginCloudWorkspaceRename(machine: machine, workspaceID: workspace.id, name: "second") - catalog.rollbackCloudWorkspaceRename(first) - #expect(catalog.pendingCloudWorkspaceRenameName(machine: machine, workspaceID: workspace.id) == "second") - #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first?.name == "second") - catalog.rollbackCloudWorkspaceRename(second) - #expect(catalog.pendingCloudWorkspaceRenameName(machine: machine, workspaceID: workspace.id) == nil) - #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first?.name == "main") - } } diff --git a/tests/fixtures/codex_writer_restore/Harness.swift b/tests/fixtures/codex_writer_restore/Harness.swift new file mode 100644 index 000000000000..926393bfc030 --- /dev/null +++ b/tests/fixtures/codex_writer_restore/Harness.swift @@ -0,0 +1,85 @@ +import CMUXAgentLaunch +import Darwin +import Foundation + +/// Only the surrounding CLI shell is stubbed; restore planning and exec are production code. +struct CMUXCLI { + struct RestoreRecord { + let mode: String + let kind: String + let checkpointID: String? + let workingDirectory: String? + let launchCommand: AgentLaunchCommand? + } + + struct RestoreError: Error, CustomStringConvertible { + let description: String + } + + func loggedRestoreError( + stage: String, + detail: String? = nil, + errorCode: Int32? = nil, + message: String + ) -> RestoreError { + RestoreError(description: "\(stage): \(message)") + } +} + +final class SocketClient { + func close() {} +} + +@main +struct RestoreHarness { + static func main() { + do { + let cli = CMUXCLI() + let args = CommandLine.arguments + let environment = ProcessInfo.processInfo.environment + let mode = args[1] + let executable = args[2] + let directory = args[3] + let sessionID = args[4] + let saved = ["CODEX_HOME": environment["SAVED_CODEX_HOME"] ?? environment["CODEX_HOME"]!] + if mode == "legacy" || mode == "legacy-noncanonical" || mode == "ambiguous-legacy" || mode == "remote" { + let remote = mode == "remote" ? " --remote ws://example.invalid" : "" + let command = mode != "ambiguous-legacy" + ? "env CODEX_HOME='\(saved["CODEX_HOME"]!)' '\(executable)'\(remote) resume \(sessionID)" + : "'\(executable)' resume \(sessionID)" + let recordMode = mode == "legacy-noncanonical" ? "resumeAgent " : "resumeAgent" + let recordKind = mode == "legacy-noncanonical" ? " codex " : "codex" + try cli.execLegacyRestoreRecord( + command, + record: CMUXCLI.RestoreRecord( + mode: recordMode, kind: recordKind, checkpointID: sessionID, + workingDirectory: directory, launchCommand: nil + ), + environment: environment, client: SocketClient() + ) + } else { + let applied = try cli.applyRestoreWorkingDirectory(directory) + let launch = AgentLaunchCommand( + arguments: [executable, "--model", "model with spaces", "-c", "test='quoted value'"], + workingDirectory: directory, environment: saved, source: "test" + ) + let request = AgentRestoreRequest( + mode: .resumeAgent, kind: mode == "other-provider" ? "claude" : "codex", + checkpointID: sessionID, source: "test", workingDirectory: applied, + environment: saved, launchCommand: mode == "remote" ? nil : launch, + preparedArguments: mode == "remote" ? [executable, "--remote", "ws://example.invalid", "resume", sessionID] : nil, + observedPermissionMode: nil + ) + guard let invocation = AgentRestorePlanner(executableFileResolver: AgentRestoreExecutableFileResolver()) + .invocation(for: request, ambientEnvironment: environment) else { + throw CMUXCLI.RestoreError(description: "fixture planning failed") + } + try cli.execRestoreInvocation(invocation, appliedWorkingDirectory: applied) + } + exit(90) + } catch { + FileHandle.standardError.write(Data("\(error)\n".utf8)) + exit(1) + } + } +} diff --git a/tests/test_codex_writer_restore.py b/tests/test_codex_writer_restore.py new file mode 100644 index 000000000000..fe7a14a6ee09 --- /dev/null +++ b/tests/test_codex_writer_restore.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Exercise the production CLI exec boundary, with synthetic homes and a fake agent. + +No app, socket, real Codex session, or user lock is opened. CMUX_RESTORE_SOURCE_REF +selects a base revision to prove the same behavior assertions fail without the fix. +The exported source is a disposable package fixture, never a git worktree. +""" +import fcntl +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SESSION = "01a06e0d-8793-7f33-b044-2b49a10c2260" + + +class CodexWriterRestoreTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.build_root = tempfile.TemporaryDirectory(prefix="cmux-writer-cli-build-") + cls.addClassCleanup(cls.build_root.cleanup) + build = Path(cls.build_root.name) + source_ref = os.environ.get("CMUX_RESTORE_SOURCE_REF") + source = ROOT + if source_ref: + source = build / "base" + source.mkdir() + archive = build / "source.tar" + with archive.open("wb") as output: + subprocess.run(["git", "archive", source_ref, "Packages/macOS/CMUXAgentLaunch", "CLI", "Sources"], cwd=ROOT, stdout=output, check=True) + with tarfile.open(archive) as contents: + contents.extractall(source, filter="data") + target = build / "Sources" / "RestoreHarness" + target.mkdir(parents=True) + shutil.copy(ROOT / "tests/fixtures/codex_writer_restore/Harness.swift", target) + for filename in ["CLI/CMUXCLI+RestoreExecution.swift", "CLI/CMUXCLI+CodexWriterRestore.swift", "Sources/CodexWriterRestoreMessage.swift"]: + if (source / filename).exists(): + shutil.copy(source / filename, target) + package = source / "Packages/macOS/CMUXAgentLaunch" + (build / "Package.swift").write_text(f'''// swift-tools-version: 6.0 +import PackageDescription +let package = Package(name: "RestoreHarness", platforms: [.macOS(.v14)], + dependencies: [.package(path: {json.dumps(str(package))})], + targets: [.executableTarget(name: "RestoreHarness", dependencies: [ + .product(name: "CMUXAgentLaunch", package: "CMUXAgentLaunch")])]) +''') + compiled = subprocess.run(["swift", "build", "--package-path", str(build), "--jobs", "4"], capture_output=True, text=True) + if compiled.returncode: + raise RuntimeError(compiled.stdout + compiled.stderr) + if "warning:" in compiled.stderr: + raise RuntimeError(compiled.stderr) + bin_path = subprocess.check_output(["swift", "build", "--package-path", str(build), "--show-bin-path"], text=True).strip() + cls.harness = Path(bin_path) / "RestoreHarness" + + def setUp(self): + self.fixture = tempfile.TemporaryDirectory(prefix="cmux-writer-cli-") + self.addCleanup(self.fixture.cleanup) + self.root = Path(self.fixture.name).resolve() + self.home = self.root / "account" + self.other = self.root / "other-account" + self.cwd = self.root / "project with spaces" + for path in [self.home / "thread-writer-locks", self.other, self.cwd]: + path.mkdir(parents=True) + self.lock = self.home / "thread-writer-locks" / f"{SESSION}.lock" + self.agent = self.root / "codex" + # An executable fixture records what actually reached execve. + self.agent.write_text('''#!/usr/bin/python3 +import json, os, sys +print(json.dumps({"argv": sys.argv, "cwd": os.getcwd(), "home": os.environ.get("CODEX_HOME"), "marker": os.environ.get("CMUX_TEST_MARKER")})) +''') + self.agent.chmod(0o700) + + def hold_lock(self): + handle = self.lock.open("w+") + self.addCleanup(handle.close) + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + return handle + + def restore(self, mode="structured", saved_home=None, ambient_home=None): + env = {"PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "HOME": str(self.root), "SHELL": "/bin/sh", + "CODEX_HOME": str(ambient_home or self.home), "SAVED_CODEX_HOME": str(saved_home or self.home), + "CMUX_TEST_MARKER": "kept verbatim"} + return subprocess.run([str(self.harness), mode, str(self.agent), str(self.cwd), SESSION], + env=env, text=True, capture_output=True, timeout=15) + + def test_locked_session_stops_before_agent_exec(self): + handle = self.hold_lock() + result = self.restore() + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("active writer", result.stderr) + self.assertNotIn(self.lock.name, result.stderr) + self.assertNotIn(str(self.root), result.stderr) + self.assertEqual(result.stdout, "") + self.assertTrue(self.lock.exists()) + # A second descriptor still cannot acquire our lock: no unlock/removal. + with self.lock.open() as probe: + with self.assertRaises(BlockingIOError): + fcntl.flock(probe, fcntl.LOCK_EX | fcntl.LOCK_NB) + self.assertFalse(handle.closed) + + def test_unlocked_and_released_files_allow_exact_launch(self): + handle = self.hold_lock() + fcntl.flock(handle, fcntl.LOCK_UN) + result = self.restore() + self.assertEqual(result.returncode, 0, result.stderr) + launch = json.loads(result.stdout) + self.assertEqual(launch["cwd"], str(self.cwd)) + self.assertEqual(Path(launch["home"]).resolve(), self.home) + self.assertEqual(launch["marker"], "kept verbatim") + self.assertIn("model with spaces", launch["argv"]) + self.assertIn("test='quoted value'", launch["argv"]) + self.assertIn(SESSION, launch["argv"]) + self.assertTrue(self.lock.exists()) + + def test_saved_account_wins_over_ambient(self): + self.hold_lock() + blocked = self.restore(ambient_home=self.other) + self.assertNotEqual(blocked.returncode, 0, blocked.stdout) + allowed = self.restore(saved_home=self.other) + self.assertEqual(allowed.returncode, 0, allowed.stderr) + self.assertEqual(Path(json.loads(allowed.stdout)["home"]).resolve(), self.other) + + def test_remote_provider_does_not_consult_local_lock(self): + self.hold_lock() + result = self.restore(mode="remote") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("--remote", json.loads(result.stdout)["argv"]) + + def test_legacy_explicit_home_is_guarded(self): + handle = self.hold_lock() + blocked = self.restore(mode="legacy", ambient_home=self.other) + self.assertNotEqual(blocked.returncode, 0, blocked.stdout) + self.assertIn("active writer", blocked.stderr) + fcntl.flock(handle, fcntl.LOCK_UN) + allowed = self.restore(mode="legacy") + self.assertEqual(allowed.returncode, 0, allowed.stderr) + + def test_legacy_noncanonical_record_is_still_guarded(self): + handle = self.hold_lock() + blocked = self.restore(mode="legacy-noncanonical") + self.assertNotEqual(blocked.returncode, 0, blocked.stdout) + self.assertIn("active writer", blocked.stderr) + self.assertEqual(blocked.stdout, "") + fcntl.flock(handle, fcntl.LOCK_UN) + allowed = self.restore(mode="legacy-noncanonical") + self.assertEqual(allowed.returncode, 0, allowed.stderr) + + def test_ambiguous_legacy_does_not_guess_account(self): + result = self.restore(mode="ambiguous-legacy") + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("older shell-only", result.stderr) + + +if __name__ == "__main__": + unittest.main()