diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b265744c988..eef8ad8e20a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -783,12 +783,12 @@ jobs: echo "::warning::Passwordless sudo unavailable; XCTest will use its default automation-mode setup" fi - - name: Run bundled command PATH regression + - name: Run remote restore and bundled command regressions if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }} run: | - # The tolerant full-suite step accepts ordinary Swift Testing failures. - # Keep the shell-resolution integration test non-tolerant so losing - # cmux's bundled commands from PATH cannot pass a shard. + # Swift Testing assertion failures are tolerated in the full sharded + # app-host suite. Keep these focused correctness boundaries + # non-tolerant so a local cwd or missing bundled command cannot pass. set -euo pipefail SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" if ! command -v fish >/dev/null 2>&1; then @@ -803,6 +803,12 @@ jobs: -disableAutomaticPackageResolution \ -destination "platform=macOS" \ CMUX_SKIP_ZIG_BUILD=1 \ + -only-testing:cmuxTests/RemoteAgentRestoreWorkingDirectoryTests \ + -only-testing:cmuxTests/RemoteResumeBindingTests/surfaceRestoreRecordExactNilCwdDoesNotUseCapturedFallbacks \ + -only-testing:cmuxTests/RemoteResumeBindingTests/legacyPersistentAgentHookBindingWithoutCwdPolicyReattachesWithoutReplayingStartupInput \ + -only-testing:cmuxTests/RemoteResumeBindingTests/authenticatedPersistentSSHRefreshOverridesStaleUnavailablePolicy \ + -only-testing:cmuxTests/SessionPersistenceResumeBindingTests/unavailableSelectionErasesPersistedAgentRestoreRecipe \ + -only-testing:cmuxTests/SessionPersistenceResumeBindingTests/resumeBindingSelectionChangesAutosaveFingerprint \ -only-testing:cmuxTests/CmuxBundledBinPathIntegrationTests \ test diff --git a/CLI/CMUXCLI+SessionsListForkStartupInput.swift b/CLI/CMUXCLI+SessionsListForkStartupInput.swift index 662f8e18e3b6..c25233d6ee32 100644 --- a/CLI/CMUXCLI+SessionsListForkStartupInput.swift +++ b/CLI/CMUXCLI+SessionsListForkStartupInput.swift @@ -37,7 +37,8 @@ extension CMUXCLI { let workingDirectory = sessionsListNormalized(launchCommand?.workingDirectory ?? record.cwd) let sanitizedCommandParts = AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( from: commandParts, - workingDirectory: workingDirectory + workingDirectory: workingDirectory, + agentKind: agent ) let shellCommand = agent == "codex" ? AgentResumeArgv.renderedPortableCodexResumeShellCommand( diff --git a/CLI/cmux.swift b/CLI/cmux.swift index df4794fc9ca5..6ba39692b496 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -32079,8 +32079,7 @@ struct CMUXCLI { let cwd = normalizedHookValue(workingDirectory) let sanitizedCommandParts = AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( - from: commandParts, - workingDirectory: cwd + from: commandParts, workingDirectory: cwd, agentKind: kind ) let resumeCommandParts = kind == "hermes-agent" ? hermesAgentArgumentsByReplacingOpenAICodexProvider(sanitizedCommandParts) diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxConnectivityPeerSessionTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxConnectivityPeerSessionTests.swift index 4e8988c82a43..16c478a79cee 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxConnectivityPeerSessionTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxConnectivityPeerSessionTests.swift @@ -26,26 +26,28 @@ struct CmxConnectivityPeerSessionTests { _ = try await first.value _ = try await second.value - #expect(await builder.callCount() == 1) #expect(await peer.snapshot().phase == .connected) #expect(await peer.snapshot().connectionGeneration == 1) } - @Test func onePeerTraceUsesOneAliasAndOneEstablishedSessionEvent() async throws { let request = try Self.request() let peerID = try CmxConnectivityPeerID(request: request) let log = DiagnosticLog(capacity: 32, role: .mobileClient) let admitted = TestConnectivitySession(continuityID: 17) - let builder = SequencedConnectivitySessionBuilder(sessions: [admitted]) + let builder = GatedConnectivitySessionBuilder(session: admitted) let peer = CmxConnectivityPeerSession( peerID: peerID, buildSession: { request in try await builder.build(request) }, diagnosticLog: log ) - - _ = try await peer.connectedSession(for: request) + // The gated builder parks every dial until released. Awaiting the + // dial before releasing the gate would deadlock this test. + let dial = Task { try await peer.connectedSession(for: request) } + try await Self.waitUntil { await builder.callCount() == 1 } + await builder.release() + _ = try await dial.value await peer.releaseControl(ownerID: UUID()) await peer.invalidate() #expect(await waitForDiagnosticProcessedCount(log, atLeast: 3)) @@ -57,7 +59,6 @@ struct CmxConnectivityPeerSessionTests { #expect(lifecycle.compactMap(\.surface).count == lifecycle.count) #expect(Set(lifecycle.compactMap(\.surface)).count == 1) } - @Test func nextControlOwnerWaitsAndReleaseClosesThePeerConnection() async throws { let request = try Self.request() @@ -76,7 +77,6 @@ struct CmxConnectivityPeerSessionTests { ) let firstOwner = UUID() let secondOwner = UUID() - _ = try await peer.acquireControl(for: request, ownerID: firstOwner) let secondAcquire = Task { try await peer.acquireControl(for: routeVariant, ownerID: secondOwner) diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxAsyncLatch.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxAsyncLatch.swift index 43e05354b4c5..1874d8cfa39a 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxAsyncLatch.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxAsyncLatch.swift @@ -17,3 +17,16 @@ actor IrxAsyncLatch { pending.forEach { $0.resume() } } } + +enum IrxAsyncWait { + static func until( + _ condition: @escaping @Sendable () async -> Bool + ) async throws { + for _ in 0..<20 { + if await condition() { return } + try await Task.sleep(for: .milliseconds(50)) + } + struct TimedOut: Error {} + throw TimedOut() + } +} diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxLiveQUICTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxLiveQUICTests.swift index d4f245fb17bb..f25fa58b1382 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxLiveQUICTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxLiveQUICTests.swift @@ -694,7 +694,7 @@ struct IrxLiveQUICTests { ) ) await retired.connection.close(code: .explicitRedial, origin: .local) - try await Task.sleep(for: .milliseconds(150)) + try await IrxAsyncWait.until { await engine.currentSession() == nil } #expect(await engine.currentSession() == nil) #expect( journal.counterSnapshot()["dial-started"] ?? 0 diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxLivenessTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxLivenessTests.swift index f0f0ec387ff6..0151804e2f77 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxLivenessTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxLivenessTests.swift @@ -57,9 +57,6 @@ struct IrxLivenessTests { } try await waitUntil { await host.probeCount == 1 } await session.connection.setApplicationActive(false) - // Deliberately outlast the cancelled probe deadline. This represents time - // during which iOS is backgrounded and cannot perform application work. - try await Task.sleep(for: .milliseconds(250)) #expect(host.journal.counterSnapshot()["miss", default: 0] == 0) #expect(await host.deathCount == 0) #expect(await host.probeCount == 1) diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxRelayCredentialInstallerTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxRelayCredentialInstallerTests.swift index 9e42675ca596..3a4748cbd014 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxRelayCredentialInstallerTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxRelayCredentialInstallerTests.swift @@ -36,15 +36,17 @@ struct IrxRelayCredentialInstallerTests { let native = RelayInstallProbe(failFirst: true) let gate = IrxRelayCredentialRotationGate() let generation = await gate.begin() + let releaseRetry = IrxAsyncLatch() let installer = IrxRelayCredentialInstaller(installed: [], journal: IrxLiveTestSupport.journal(), sleep: { _ in await native.noteSleep() - try await Task.sleep(for: .milliseconds(100)) + await releaseRetry.wait() }, install: { try await native.install($0) }) await installer.replace(with: [credential("old")], ownership: .init(gate: gate, generation: generation)) try await waitUntil { await native.sleepCount == 1 } await gate.invalidate() - try await Task.sleep(for: .milliseconds(200)) + await releaseRetry.signal() + try await waitUntil { await native.finished == 1 } #expect(await native.tokens == ["old"]) let current = await gate.begin() @@ -135,6 +137,7 @@ private actor RelayInstallProbe { private(set) var maximumConcurrent = 0 private(set) var tokens: [String] = [] private(set) var completed = 0 + private(set) var finished = 0 private(set) var sleepCount = 0 init(failFirst: Bool = false, holdFirst: Bool = false) { @@ -146,7 +149,10 @@ private actor RelayInstallProbe { tokens.append(credential.token) concurrent += 1 maximumConcurrent = max(maximumConcurrent, concurrent) - defer { concurrent -= 1 } + defer { + concurrent -= 1 + finished += 1 + } let first = tokens.count == 1 if holdFirst, first { await withCheckedContinuation { releaseWaiter = $0 } } if failFirst, first { throw Failure.unavailable } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalLaneCoordinator.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalLaneCoordinator.swift index a9603ebf3e4e..a335041952bb 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalLaneCoordinator.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalLaneCoordinator.swift @@ -126,15 +126,24 @@ actor MobileTerminalLaneCoordinator { return } let id = UUID() + let hasProvider: Bool + switch configuration.mode { + case .output: + hasProvider = provider != nil + case .inputOnly: + hasProvider = inputOnlyProvider != nil || provider != nil + } entriesByKey[key] = Entry( id: id, configuration: configuration, - phase: .opening, + phase: hasProvider ? .opening : .failed, lane: nil, task: nil, outputReady: false ) - launch(key: key, id: id) + if hasProvider { + launch(key: key, id: id) + } } func resume(surfaceID: String) { diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift index bee477ae1386..1a9bea9a6b03 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift @@ -237,16 +237,39 @@ public enum AgentLaunchSanitizer { } return false } + + /// Removes captured cwd options before an argument boundary. + /// + /// - Parameters: + /// - args: The captured command arguments to sanitize. + /// - workingDirectory: The saved cwd whose matching options should be removed. + /// - agentKind: The exact built-in kind, or `nil` for a custom registration. + /// - removeAllWorkingDirectoryOptions: Whether to remove every cwd option regardless of value. + /// - Returns: Sanitized arguments while preserving content after `--`. public static func removingSavedWorkingDirectoryOptions( from args: [String], - workingDirectory: String? + workingDirectory: String?, + agentKind: String? = nil, + removeAllWorkingDirectoryOptions: Bool = false ) -> [String] { - guard let workingDirectory = normalizedWorkingDirectory(workingDirectory) else { + let savedWorkingDirectory = normalizedWorkingDirectory(workingDirectory) + guard removeAllWorkingDirectoryOptions || savedWorkingDirectory != nil else { return args } - - let valueOptions: Set = ["--cd", "-C", "--cwd", "--workspace", "-w"] + let optionPolicy = AgentWorkingDirectoryOptionPolicy( + agentKind: agentKind, + builtInAgentKind: agentKind + ) + let valueOptions = optionPolicy.valueOptions + let unconditionallyRemovableValueOptions = optionPolicy.unconditionallyRemovableValueOptions + let attachedShortValueOptions = optionPolicy.attachedShortValueOptions let optionPrefixes = valueOptions.map { "\($0)=" } + let shouldRemoveValue: (String, String) -> Bool = { option, value in + (removeAllWorkingDirectoryOptions && unconditionallyRemovableValueOptions.contains(option)) || + savedWorkingDirectory.map { + workingDirectoryValue(value, matches: $0) + } == true + } var result: [String] = [] var index = 0 while index < args.count { @@ -255,15 +278,45 @@ public enum AgentLaunchSanitizer { result.append(contentsOf: args[index...]) break } - if valueOptions.contains(arg), - index + 1 < args.count, - workingDirectoryValue(args[index + 1], matches: workingDirectory) { - index += 2 - continue + if valueOptions.contains(arg) { + guard index + 1 < args.count else { + if removeAllWorkingDirectoryOptions && + unconditionallyRemovableValueOptions.contains(arg) { + index += 1 + continue + } + result.append(arg) + index += 1 + continue + } + let value = args[index + 1] + if removeAllWorkingDirectoryOptions && + unconditionallyRemovableValueOptions.contains(arg) && + value.hasPrefix("-") && value != "-" { + // An option-looking token cannot be a reliable cwd value. + // Remove only the incomplete cwd option so the next option + // remains available to the replayed agent command. + index += 1 + continue + } + if shouldRemoveValue(arg, value) { + index += 2 + continue + } } if let prefix = optionPrefixes.first(where: { arg.hasPrefix($0) }) { + let option = String(prefix.dropLast()) let value = String(arg.dropFirst(prefix.count)) - if workingDirectoryValue(value, matches: workingDirectory) { + if shouldRemoveValue(option, value) { + index += 1 + continue + } + } + if let option = attachedShortValueOptions.first(where: { + arg.count > $0.count && arg.hasPrefix($0) + }) { + let value = String(arg.dropFirst(option.count)) + if shouldRemoveValue(option, value) { index += 1 continue } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift index fb5de676c94f..6760263055db 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestorePlanner.swift @@ -57,7 +57,8 @@ public struct AgentRestorePlanner: Sendable { sanitizedArguments = workingDirectories.reduce(plannedArguments.values) { AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( from: $0, - workingDirectory: $1 + workingDirectory: $1, + agentKind: kind ) } } else { diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreWorkingDirectorySelection.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreWorkingDirectorySelection.swift new file mode 100644 index 000000000000..f736ffa8ac1b --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentRestoreWorkingDirectorySelection.swift @@ -0,0 +1,112 @@ +import Foundation + +/// Defines which persisted working-directory values an agent restore may trust. +public enum AgentRestoreWorkingDirectorySelection: Codable, Equatable, Hashable, Sendable { + /// Uses the preferred value first, then permits captured snapshot fallbacks. + case recordedFallback(preferred: String?) + /// Uses only the supplied value; an explicit `nil` remains `nil`. + case exact(String?) + /// Prevents reconstruction because a required trusted directory is unavailable. + case unavailable + + /// Whether the selection permits an agent resume or fork command. + public var permitsResume: Bool { + switch self { + case .recordedFallback, .exact: + true + case .unavailable: + false + } + } + + /// Whether captured argv working-directory options must be removed regardless of value. + public var discardsRecordedCwdOptions: Bool { + switch self { + case .recordedFallback: + false + case .exact, .unavailable: + true + } + } + + /// Resolves a normalized directory without weakening this selection's trust boundary. + /// + /// - Parameters: + /// - snapshotWorkingDirectory: The cwd recorded on the agent snapshot. + /// - launchWorkingDirectory: The cwd recorded with the captured launch. + /// - Returns: The first permitted non-empty directory, or `nil`. + public func resolved( + snapshotWorkingDirectory: String?, + launchWorkingDirectory: String? + ) -> String? { + let candidates: [String?] = switch self { + case .recordedFallback(let preferred): + [preferred, snapshotWorkingDirectory, launchWorkingDirectory] + case .exact(let workingDirectory): + [workingDirectory] + case .unavailable: + [] + } + for candidate in candidates { + guard let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + continue + } + return trimmed + } + return nil + } + + /// Applies another restriction without allowing it to replace a stricter stored policy. + /// + /// `unavailable` and exact `nil` always remain restrictive. A stored exact non-empty + /// directory also remains authoritative over a different proposed directory. + /// Two recorded-fallback selections merge their preferred values, retaining the stored + /// value when the proposed selection has no preferred directory (including blank text). + /// + /// - Parameter proposed: A call-site restriction to combine with this stored selection. + /// - Returns: The stricter effective selection. + public func restricted(by proposed: AgentRestoreWorkingDirectorySelection) -> Self { + if case .unavailable = self { return .unavailable } + if case .unavailable = proposed { return .unavailable } + + switch self { + case .exact(let storedWorkingDirectory): + if case .exact(nil) = proposed { + return .exact(nil) + } + return .exact(storedWorkingDirectory) + case .recordedFallback(let storedPreferred): + guard case .recordedFallback(let proposedPreferred) = proposed else { + return proposed + } + let normalizedProposedPreferred = proposedPreferred?.trimmingCharacters( + in: .whitespacesAndNewlines + ) + let mergedPreferred = normalizedProposedPreferred?.isEmpty == false + ? proposedPreferred + : storedPreferred + return .recordedFallback(preferred: mergedPreferred) + case .unavailable: + return .unavailable + } + } + + /// Applies a new authoritative remote observation without weakening an unavailable policy. + /// + /// Ordinary restore entrypoints use ``restricted(by:)`` so captured or caller-provided + /// directories cannot replace a stored exact value. The remote snapshot owner uses this + /// method only after provenance validation, allowing a later exact report to replace an + /// earlier exact value (including exact `nil`). + /// + /// - Parameter proposed: A provenance-validated selection from the latest remote snapshot. + /// - Returns: The refreshed selection, preserving `unavailable` as terminal. + public func refreshedByAuthoritativeRemoteSelection( + _ proposed: AgentRestoreWorkingDirectorySelection + ) -> Self { + if case .unavailable = self { return .unavailable } + if case .unavailable = proposed { return .unavailable } + if case .exact = proposed { return proposed } + return restricted(by: proposed) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentWorkingDirectoryOptionPolicy.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentWorkingDirectoryOptionPolicy.swift new file mode 100644 index 000000000000..07864053aa8b --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentWorkingDirectoryOptionPolicy.swift @@ -0,0 +1,91 @@ +import Foundation + +/// Describes agent argv options that carry a working-directory value. +public struct AgentWorkingDirectoryOptionPolicy: Sendable { + /// Options whose cwd value is stored in the following token or after `=`. + public let valueOptions: Set + /// Options that are unambiguously cwd-bearing and may be removed without + /// comparing their value to a captured directory. + public let unconditionallyRemovableValueOptions: Set + /// Short options whose cwd value may be attached to the option token. + public let attachedShortValueOptions: Set + + /// Creates the option policy for an agent kind. + /// + /// Ambiguous short options are enabled only for agents where their cwd meaning is known. + /// An unknown kind retains legacy split `-C ` matching, but does not interpret an + /// arbitrary token beginning with `-C` as an attached cwd value. + /// + /// - Parameter agentKind: The agent kind, when known. + public init(agentKind: String? = nil) { + self.init(agentKind: agentKind, builtInAgentKind: agentKind) + } + + /// Creates an option policy with an explicit built-in identity. + /// + /// `agentKind` describes the captured command and may be a user-defined + /// Vault id that happens to reuse a built-in spelling. `builtInAgentKind` + /// is therefore the only value allowed to enable provider-specific + /// cwd flags that are safe to remove without comparing their value. Pass + /// `nil` for a custom registration so profile/workspace flags such as + /// Kimi's `-w` are preserved during an exact restore. + /// + /// - Parameters: + /// - agentKind: The captured command kind, when known. + /// - builtInAgentKind: The exact cmux built-in kind, or `nil` for a custom registration. + public init(agentKind: String?, builtInAgentKind: String?) { + var valueOptions: Set = [ + "--cd", + "--cwd", + "--work-dir", + "--workspace", + ] + // `--workspace` remains value-matchable, but is intentionally omitted + // from the unconditional set because its meaning varies by agent (and + // custom agents may use it as a profile/workspace selector). + var unconditionallyRemovableValueOptions: Set = [ + "--cd", + "--cwd", + "--work-dir", + ] + var attachedShortValueOptions: Set = [] + + let normalizedBuiltInAgentKind = builtInAgentKind? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + switch normalizedBuiltInAgentKind { + case "codex": + valueOptions.insert("-C") + unconditionallyRemovableValueOptions.insert("-C") + attachedShortValueOptions.insert("-C") + case "kimi": + valueOptions.insert("-w") + unconditionallyRemovableValueOptions.insert("-w") + attachedShortValueOptions.insert("-w") + case "qoder": + // Qoder's --workspace selects a saved workspace; it is not a cwd + // override even when its value happens to look like a directory. + valueOptions.remove("--workspace") + unconditionallyRemovableValueOptions.remove("--workspace") + valueOptions.insert("-w") + unconditionallyRemovableValueOptions.insert("-w") + attachedShortValueOptions.insert("-w") + case "cursor": + // Cursor's --workspace selects the cwd. Unlike Qoder's profile + // selector, it is safe to remove when remote cwd is authoritative. + unconditionallyRemovableValueOptions.insert("--workspace") + case .some(_): + // A caller supplied an identity that is not one of the known + // built-ins. Retain the conservative legacy `-C` matching only. + valueOptions.insert("-C") + case .none: + // Unknown agents may use `-C` for a non-cwd setting. Keep the + // legacy value-matching behavior instead of stripping it blindly. + valueOptions.insert("-C") + } + + self.valueOptions = valueOptions + self.unconditionallyRemovableValueOptions = unconditionallyRemovableValueOptions + self.attachedShortValueOptions = attachedShortValueOptions + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerAdditionalTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerAdditionalTests.swift new file mode 100644 index 000000000000..cfea19ab366b --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerAdditionalTests.swift @@ -0,0 +1,163 @@ +import CMUXAgentLaunch +import Testing + +@Suite("Agent launch sanitizer additional coverage") +struct AgentLaunchSanitizerAdditionalTests { + @Test("Preserves ambiguous short options without agent semantics") + func preservesAmbiguousShortWorkingDirectoryOptions() { + let splitWorktree = ["cmux", "claude-teams", "-w", "/tmp/team-worktree", "--model", "sonnet"] + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: splitWorktree, + workingDirectory: "/tmp/team-worktree" + ) == splitWorktree + ) + + let attachedWorktree = ["cmux", "claude-teams", "-w/tmp/team-worktree", "--model", "sonnet"] + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: attachedWorktree, + workingDirectory: nil, + removeAllWorkingDirectoryOptions: true + ) == attachedWorktree + ) + + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: ["qoder", "-w", "/tmp/project", "--model", "best"], + workingDirectory: "/tmp/project", + agentKind: "qoder" + ) == ["qoder", "--model", "best"] + ) + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: ["kimi", "--resume", "session", "-w/local/repo", "--model", "kimi-k2"], + workingDirectory: nil, + agentKind: "kimi", + removeAllWorkingDirectoryOptions: true + ) == ["kimi", "--resume", "session", "--model", "kimi-k2"] + ) + } + + @Test("Removes every cwd option while preserving arguments after the boundary") + func removesWorkingDirectoryOptions() { + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: ["kimi", "--resume", "session", "--work-dir", "/local/repo", "--model", "kimi-k2"], + workingDirectory: nil, + removeAllWorkingDirectoryOptions: true + ) == ["kimi", "--resume", "session", "--model", "kimi-k2"] + ) + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: ["grok", "-r", "session", "--cwd=/local/repo", "--", "--cwd", "prompt text"], + workingDirectory: nil, + removeAllWorkingDirectoryOptions: true + ) == ["grok", "-r", "session", "--", "--cwd", "prompt text"] + ) + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: ["grok", "-r", "session", "--cwd", "--", "--cwd", "prompt text"], + workingDirectory: nil, + removeAllWorkingDirectoryOptions: true + ) == ["grok", "-r", "session", "--", "--cwd", "prompt text"] + ) + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: ["grok", "-r", "session", "--cwd"], + workingDirectory: nil, + removeAllWorkingDirectoryOptions: true + ) == ["grok", "-r", "session"] + ) + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: ["qoder", "--workspace", "/tmp/other", "--cwd", "/tmp/project"], + workingDirectory: nil, + agentKind: "qoder", + removeAllWorkingDirectoryOptions: true + ) == ["qoder", "--workspace", "/tmp/other"] + ) + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: ["cursor-agent", "resume", "session", "--workspace", "/local/repo", "--model", "fast"], + workingDirectory: nil, + agentKind: "cursor", + removeAllWorkingDirectoryOptions: true + ) == ["cursor-agent", "resume", "session", "--model", "fast"] + ) + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: ["grok", "-r", "session", "--cwd", "--model", "grok-4"], + workingDirectory: nil, + removeAllWorkingDirectoryOptions: true + ) == ["grok", "-r", "session", "--model", "grok-4"] + ) + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: ["codex", "resume", "session", "-C/local/repo", "--model", "gpt-5.4"], + workingDirectory: nil, + agentKind: "codex", + removeAllWorkingDirectoryOptions: true + ) == ["codex", "resume", "session", "--model", "gpt-5.4"] + ) + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: ["kimi", "--resume", "session", "-Continue", "--model", "kimi-k2"], + workingDirectory: nil, + agentKind: "kimi", + removeAllWorkingDirectoryOptions: true + ) == ["kimi", "--resume", "session", "-Continue", "--model", "kimi-k2"] + ) + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: ["custom-agent", "-Color", "always"], + workingDirectory: nil, + agentKind: "custom-agent", + removeAllWorkingDirectoryOptions: true + ) == ["custom-agent", "-Color", "always"] + ) + let unknownAgentConfig = ["custom-agent", "-C", "/etc/custom-agent.conf", "--session", "session-id"] + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: unknownAgentConfig, + workingDirectory: nil, + agentKind: "custom-agent", + removeAllWorkingDirectoryOptions: true + ) == unknownAgentConfig + ) + let customWorkspaceConfig = ["custom-agent", "--workspace", "profile-a", "--cwd", "/tmp/local"] + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: customWorkspaceConfig, + workingDirectory: nil, + agentKind: "custom-agent", + removeAllWorkingDirectoryOptions: true + ) == ["custom-agent", "--workspace", "profile-a"] + ) + let customKimiProfile = ["custom-kimi", "-w", "profile-a", "--model", "custom"] + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: customKimiProfile, + workingDirectory: nil, + agentKind: nil, + removeAllWorkingDirectoryOptions: true + ) == customKimiProfile + ) + } + + @Test( + "Matches cwd option semantics without agent-kind case sensitivity", + arguments: ["Codex", "KIMI", "QoDeR"] + ) + func matchesCaseInsensitiveAgentKinds(agentKind: String) { + let option = agentKind.lowercased() == "codex" ? "-C/local/repo" : "-w/local/repo" + #expect( + AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: [agentKind, option, "--model", "test"], + workingDirectory: nil, + agentKind: agentKind, + removeAllWorkingDirectoryOptions: true + ) == [agentKind, "--model", "test"] + ) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift index 6110d5dc72f5..a00f13fa79b2 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift @@ -668,7 +668,7 @@ struct AgentLaunchSanitizerTests { #expect( AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( from: ["qoder", "-w", "/tmp/project", "--model", "best"], - workingDirectory: "/tmp/project" + workingDirectory: "/tmp/project", agentKind: "qoder" ) == ["qoder", "--model", "best"] ) } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentRestoreWorkingDirectorySelectionTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentRestoreWorkingDirectorySelectionTests.swift new file mode 100644 index 000000000000..c93f873fa155 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentRestoreWorkingDirectorySelectionTests.swift @@ -0,0 +1,108 @@ +import Foundation +import Testing +@testable import CMUXAgentLaunch + +struct AgentRestoreWorkingDirectorySelectionTests { + @Test("Exact and unavailable selections survive persistence") + func persistsRestrictiveSelections() throws { + for selection in [ + AgentRestoreWorkingDirectorySelection.exact("/home/remote/project"), + .exact(nil), + .unavailable, + ] { + let data = try JSONEncoder().encode(selection) + let decoded = try JSONDecoder().decode( + AgentRestoreWorkingDirectorySelection.self, + from: data + ) + #expect(decoded == selection) + } + } + + @Test("Stored selections cannot be weakened by later callers") + func retainsStricterSelection() { + let storedDirectory = "/home/remote/project" + let capturedDirectory = "/Users/alice/captured" + + #expect( + AgentRestoreWorkingDirectorySelection.exact(storedDirectory).restricted( + by: .recordedFallback(preferred: capturedDirectory) + ) == .exact(storedDirectory) + ) + #expect( + AgentRestoreWorkingDirectorySelection.unavailable.restricted( + by: .exact(capturedDirectory) + ) == .unavailable + ) + #expect( + AgentRestoreWorkingDirectorySelection.exact(storedDirectory).restricted( + by: .exact(nil) + ) == .exact(nil) + ) + } + + @Test("A nil fallback proposal retains the stored preferred directory") + func retainsStoredFallbackWhenProposalHasNoPreferredDirectory() { + let stored = AgentRestoreWorkingDirectorySelection.recordedFallback( + preferred: "/home/remote/project" + ) + let restricted = stored.restricted( + by: .recordedFallback(preferred: nil) + ) + + #expect(restricted == stored) + #expect( + restricted.resolved( + snapshotWorkingDirectory: "/Users/local/snapshot", + launchWorkingDirectory: "/Users/local/launch" + ) == "/home/remote/project" + ) + } + + @Test("A blank fallback proposal is treated as having no preferred directory") + func retainsStoredFallbackWhenProposalIsBlank() { + let stored = AgentRestoreWorkingDirectorySelection.recordedFallback( + preferred: "/home/remote/project" + ) + let restricted = stored.restricted( + by: .recordedFallback(preferred: " ") + ) + + #expect(restricted == stored) + #expect( + restricted.resolved( + snapshotWorkingDirectory: "/Users/local/snapshot", + launchWorkingDirectory: nil + ) == "/home/remote/project" + ) + } + + @Test("Exact nil never falls back to captured cwd values") + func exactNilDoesNotFallBack() { + #expect( + AgentRestoreWorkingDirectorySelection.exact(nil).resolved( + snapshotWorkingDirectory: "/Users/alice/snapshot", + launchWorkingDirectory: "/Users/alice/launch" + ) == nil + ) + } + + @Test("A newer authoritative exact selection refreshes an older exact value") + func refreshesAuthoritativeExactSelection() { + let initial = AgentRestoreWorkingDirectorySelection.exact("/repo-a") + #expect( + initial.refreshedByAuthoritativeRemoteSelection(.exact("/repo-b")) == + .exact("/repo-b") + ) + #expect( + AgentRestoreWorkingDirectorySelection.exact(nil) + .refreshedByAuthoritativeRemoteSelection(.exact("/repo-b")) == + .exact("/repo-b") + ) + #expect( + AgentRestoreWorkingDirectorySelection.unavailable + .refreshedByAuthoritativeRemoteSelection(.exact("/repo-b")) == + .unavailable + ) + } +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index fe1bad398ee6..201b39b7d42a 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -375,7 +375,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { \(SSHForegroundAuthenticationRetryPolicy().processTreeTerminationShellFunction()) ( trap '' HUP INT TERM; : > "$CMUX_TEST_READY_MARKER"; while :; do /bin/sleep 30; done ) & cmux_test_auth_root=$! - trap '/bin/kill -KILL "$cmux_test_auth_root" >/dev/null 2>&1 || true' EXIT + trap 'kill -KILL "$cmux_test_auth_root" >/dev/null 2>&1 || true' EXIT cmux_test_ready_attempt=0 while [ ! -f "$CMUX_TEST_READY_MARKER" ] && [ "$cmux_test_ready_attempt" -lt 300 ]; do /bin/sleep 0.01 @@ -383,8 +383,8 @@ struct SSHForegroundAuthenticationRetryPolicyTests { done test -f "$CMUX_TEST_READY_MARKER" || exit 98 cmux_ssh_terminate_auth_process_tree "$cmux_test_auth_root" 1 - /bin/kill -0 "$cmux_test_auth_root" >/dev/null 2>&1 || exit 97 - /bin/kill -KILL "$cmux_test_auth_root" >/dev/null 2>&1 || true + kill -0 "$cmux_test_auth_root" >/dev/null 2>&1 || exit 97 + kill -KILL "$cmux_test_auth_root" >/dev/null 2>&1 || true wait "$cmux_test_auth_root" 2>/dev/null || true trap - EXIT """ diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift index 5df74ef28a69..2d5c3d09ef4b 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift @@ -222,14 +222,15 @@ extension TerminalSurface { /// Test-only helper to install a runtime surface pointer directly. /// /// Most package tests pass a pointer serviced by `GhosttyRuntimeTestStubs`, - /// so the native callback wiring remains enabled by default. App-host - /// XCTest fixtures link the real GhosttyKit and sometimes use a synthetic - /// pointer only to exercise Swift teardown ownership; those callers must - /// disable native callback setup so a fake address never crosses the C ABI. + /// so clipboard and font callback wiring remains enabled by default. + /// Renderer callback wiring is opt-in because teardown fixtures may install + /// synthetic pointers without native callback ownership, while Ghostty's + /// renderer registration contract is one-shot for each runtime surface. @MainActor public func installRuntimeSurfaceForTesting( _ runtimeSurface: ghostty_surface_t, - configureNativeCallbacks: Bool = true + configureNativeCallbacks: Bool = true, + configureRendererCallbacks: Bool = false ) { let callbackContext: Unmanaged< GhosttySurfaceCallbackContext @@ -238,12 +239,23 @@ extension TerminalSurface { surfaceCallbackContext { callbackContext = existingContext } else { + let callbackTarget = TerminalSurfaceCallbackTarget(surface: self) callbackContext = Unmanaged.passRetained( GhosttySurfaceCallbackContext( surfaceHost: surfaceView, surfaceController: self, - terminalLifecycleID: terminalLifecycleId + terminalLifecycleID: terminalLifecycleId, + rendererFramePresented: { _, token in + MainActor.assumeIsolated { + callbackTarget.surface?.rendererFrameDidPresent(token: token) + } + }, + rendererFrameFailed: { _, token, status in + MainActor.assumeIsolated { + callbackTarget.surface?.rendererFrameDidFail(token: token, status: status) + } + } ) ) surfaceCallbackContext = callbackContext @@ -251,17 +263,36 @@ extension TerminalSurface { surface = runtimeSurface portalLifecycleState = .live runtimeSurfaceFreedOutOfBandForTesting = false - guard configureNativeCallbacks else { return } - _ = callbackContext.takeUnretainedValue() - .bindRuntimeClipboardSurface( - runtimeSurface, - generation: runtimeSurfaceGeneration + if configureNativeCallbacks { + _ = callbackContext.takeUnretainedValue() + .bindRuntimeClipboardSurface( + runtimeSurface, + generation: runtimeSurfaceGeneration + ) + cacheControllingTTYIdentity(for: runtimeSurface) + installFontSizeActionObservation( + on: runtimeSurface, + callbackContext: callbackContext ) - cacheControllingTTYIdentity(for: runtimeSurface) - installFontSizeActionObservation( - on: runtimeSurface, - callbackContext: callbackContext - ) + } + if configureNativeCallbacks && configureRendererCallbacks { + precondition( + ghostty_surface_set_render_presented_callback( + runtimeSurface, + terminalRendererPresentedCallback, + callbackContext.toOpaque() + ), + "test runtime surface rejected its presentation callback" + ) + precondition( + ghostty_surface_set_render_failed_callback( + runtimeSurface, + terminalRendererFailedCallback, + callbackContext.toOpaque() + ), + "test runtime surface rejected its presentation failure callback" + ) + } } #endif } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+HibernationWithoutSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+HibernationWithoutSurface.swift new file mode 100644 index 000000000000..29906d103d07 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+HibernationWithoutSurface.swift @@ -0,0 +1,39 @@ +import Foundation + +extension TerminalSurface { + /// Clears hibernation state when no native surface was ever realized. + @MainActor + func suspendRuntimeSurfaceWithoutNativeSurface(reason: String) -> Bool { + let isNewHibernation = !runtimeSurfaceSuspendedForAgentHibernation + let hasRetainedRuntimeResources = surfaceCallbackContext != nil || + manualIOContext != nil || + mobileByteTeeLease != nil + if let reservation = agentHibernationRuntimeTeardownReservation { + agentHibernationRuntimeTeardownReservation = nil + runtimeTeardown.cancelIsolatedHibernationTeardown(reservation) + } + if isNewHibernation { + // Hibernation retires the terminal and portal generations even + // when there is no native surface. Delayed hook reports and + // queued portal-host retries must not target the dormant model. + advanceTerminalLifecycleForRuntimeReplacement() + portalLifecycleGeneration &+= 1 + pendingSocketInputQueue.removeAll(keepingCapacity: false) + pendingSocketInputBytes = 0 + desiredFocusState = false + } + activePortalHostLease = nil + portalHostAuthority = nil + clearPortalHostVacancyRetries() + runtimeSurfaceSuspendedForAgentHibernation = true + mobileViewportFontFitState = nil + backgroundSurfaceStartQueued = false + backgroundSurfaceStartSource = .normal + cancelAgentCommandShimInstallLifecycle() + closeHeadlessStartupWindowIfNeeded() + if isNewHibernation || hasRetainedRuntimeResources { + retireRuntimeResourcesWithoutSurface(reason: reason) + } + return true + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift index 34ae6b332b96..875b2b9aa262 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift @@ -163,35 +163,11 @@ extension TerminalSurface { let registeredOwnerId = registry.runtimeSurfaceOwnerId(surface) guard registeredOwnerId == id, GhosttySurfaceRuntimeProbe.surfacePointerAppearsLive(surface) else { - let callbackContext = surfaceCallbackContext - invalidateRuntimeClipboardRequests(in: callbackContext, completingNativeRequests: false) - surfaceCallbackContext = nil - let manualIOContext = self.manualIOContext - self.manualIOContext = nil - let teeLease = mobileByteTeeLease - mobileByteTeeLease = nil - let retiredRemoteOutputLane = retireRemoteOutputLane() - let staleRuntimeResources = TerminalSurfaceStaleRuntimeResources( - callbackContext: callbackContext, - manualIOContext: manualIOContext, - byteTeeLease: teeLease - ) - staleRuntimeResourceReleaseTicket = runtimeTeardown.enqueueRuntimeTeardownFence( - id: UUID(), - workspaceId: tabId, - reason: "stale", - fence: { - await retiredRemoteOutputLane.drain() - }, - onCompletion: { - staleRuntimeResources.release() - } - ) + retireRuntimeResourcesWithoutSurface(reason: "stale") registry.unregisterRuntimeSurface(surface, ownerId: id) self.surface = nil activePortalHostLease = nil portalHostAuthority = nil - byteTee.dropSurface(surfaceID: id) recordTeardownRequest(reason: reason) markPortalLifecycleClosed(reason: reason) #if DEBUG @@ -375,6 +351,15 @@ extension TerminalSurface { @discardableResult @MainActor public func suspendRuntimeSurfaceForAgentHibernation(reason: String) -> Bool { + // A terminal can be hibernated before its portal ever realizes a + // native Ghostty surface (for example while a restored window is still + // hidden). There is no native resource to free in that state, so do + // not consume one of the bounded teardown reservations or reject the + // hibernation solely because both slots are occupied by unrelated + // surfaces. + if surface == nil { + return suspendRuntimeSurfaceWithoutNativeSurface(reason: reason) + } guard let teardownReservation = agentHibernationRuntimeTeardownReservation ?? runtimeTeardown.reserveIsolatedHibernationTeardown() else { diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+StaleRuntimeResources.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+StaleRuntimeResources.swift new file mode 100644 index 000000000000..2d830e4e8c1a --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+StaleRuntimeResources.swift @@ -0,0 +1,40 @@ +import Foundation + +extension TerminalSurface { + /// Retires callback userdata and output state when the native surface is + /// absent or cannot be freed safely. A failed or out-of-band realization + /// can still leave these handles alive, so they follow the same lane fence + /// as a native free before their retained references are released. + @MainActor + func retireRuntimeResourcesWithoutSurface(reason: String) { + let callbackContext = surfaceCallbackContext + surfaceCallbackContext = nil + let manualIOContext = self.manualIOContext + self.manualIOContext = nil + let teeLease = mobileByteTeeLease + mobileByteTeeLease = nil + invalidateRuntimeClipboardRequests( + in: callbackContext, + completingNativeRequests: false + ) + let retiredRemoteOutputLane = retireRemoteOutputLane() + byteTee.dropSurface(surfaceID: id) + let staleRuntimeResources = TerminalSurfaceStaleRuntimeResources( + callbackContext: callbackContext, + manualIOContext: manualIOContext, + byteTeeLease: teeLease + ) + staleRuntimeResourceReleaseTicket = runtimeTeardown.enqueueRuntimeTeardownFence( + id: UUID(), + workspaceId: tabId, + reason: reason, + fence: { + await retiredRemoteOutputLane.drain() + }, + onCompletion: { + staleRuntimeResources.release() + } + ) + } + +} diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/PresentedSurfaceFixture.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/PresentedSurfaceFixture.swift index 0a36f9beacbb..b0622d517755 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/PresentedSurfaceFixture.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/PresentedSurfaceFixture.swift @@ -9,6 +9,9 @@ private func beginRendererRealizedTracking(_ surface: UnsafeMutableRawPointer) @_silgen_name("cmux_test_ghostty_renderer_realized_reset") private func resetRendererRealizedTracking() +@_silgen_name("cmux_test_ghostty_renderer_present") +private func presentRendererFrame(_ surface: UnsafeMutableRawPointer) -> Bool + /// A surface with a live runtime pointer attached to a real (test) window with /// usable drawable geometry, presented unless the window starts hidden. @MainActor @@ -18,7 +21,10 @@ struct PresentedSurfaceFixture { let window: NSWindow let runtimeSurface: UnsafeMutableRawPointer - init(windowVisibleAtCreation: Bool = true) { + init( + windowVisibleAtCreation: Bool = true, + configureRendererCallbacks: Bool = true + ) { registry = TerminalSurfaceRegistry() let nativeView = FakeTerminalSurfaceNativeView( frame: NSRect(x: 0, y: 0, width: 800, height: 600) @@ -73,10 +79,13 @@ struct PresentedSurfaceFixture { if !windowVisibleAtCreation { surface.setRendererWindowVisible(false) } - surface.installRuntimeSurfaceForTesting(runtimeSurface) - surface.rendererRuntimeSurfaceDidCreate() - if let token = surface.rendererPresentationState.inFlightToken { - surface.rendererFrameDidPresent(token: token) + surface.installRuntimeSurfaceForTesting( + runtimeSurface, + configureRendererCallbacks: configureRendererCallbacks + ) + if configureRendererCallbacks { + surface.rendererRuntimeSurfaceDidCreate() + _ = presentRendererFrame(runtimeSurface) } } @@ -89,8 +98,8 @@ struct PresentedSurfaceFixture { } func acknowledgePendingPresentation() { - if let token = surface.rendererPresentationState.inFlightToken { - surface.rendererFrameDidPresent(token: token) + if let runtimeSurface = surface.surface { + _ = presentRendererFrame(runtimeSurface) } } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/RendererTestSupport.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/RendererTestSupport.swift new file mode 100644 index 000000000000..5d245c4958c1 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/RendererTestSupport.swift @@ -0,0 +1,59 @@ +import AppKit +import CmuxTerminalCore +import GhosttyKit +@testable import CmuxTerminal + +@_silgen_name("cmux_test_ghostty_renderer_present") +private func presentRendererFrame(_ surface: UnsafeMutableRawPointer) -> Bool + +@_silgen_name("cmux_test_ghostty_renderer_fail") +private func failRendererFrame( + _ surface: UnsafeMutableRawPointer, + _ status: Int32 +) -> Bool + +@MainActor +func acknowledgePresentation(on surface: TerminalSurface) { + guard let runtimeSurface = surface.surface else { return } + _ = presentRendererFrame(runtimeSurface) +} + +@MainActor +func failPresentation( + on surface: TerminalSurface, + status: ghostty_render_presentation_status_e +) -> Bool { + guard let runtimeSurface = surface.surface else { return false } + return failRendererFrame(runtimeSurface, Int32(status.rawValue)) +} + +@MainActor +func installRendererCallbackContext( + on surface: TerminalSurface, + scheduler: FakeRendererRealizationScheduler +) -> Unmanaged { + let callbackTarget = TerminalSurfaceCallbackTarget(surface: surface) + let callbackContext = Unmanaged.passRetained(GhosttySurfaceCallbackContext( + surfaceHost: surface.surfaceView, + surfaceController: surface, + terminalLifecycleID: surface.terminalLifecycleId, + rendererMailboxDidDrain: { surfaceID in + MainActor.assumeIsolated { + scheduler.scheduleRendererPresentationRepair(surfaceID: surfaceID) + } + }, + rendererFramePresented: { _, token in + MainActor.assumeIsolated { + callbackTarget.surface?.rendererFrameDidPresent(token: token) + } + }, + rendererFrameFailed: { _, token, status in + MainActor.assumeIsolated { + callbackTarget.surface?.rendererFrameDidFail(token: token, status: status) + } + } + )) + surface.surfaceCallbackContext?.release() + surface.surfaceCallbackContext = callbackContext + return callbackContext +} diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift index 9aebfe9c0c75..4e28ddfe2624 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift @@ -11,7 +11,7 @@ import Testing @MainActor @Suite(.serialized) struct TerminalSurfaceRendererCallbackTests { @Test func registeredPresentationCallbackAcknowledgesThePendingToken() { - let fixture = PresentedSurfaceFixture() + let fixture = PresentedSurfaceFixture(configureRendererCallbacks: false) defer { fixture.tearDown() } let surface = fixture.surface let context = installCallbackContext(on: surface) @@ -26,6 +26,13 @@ import Testing terminalRendererFailedCallback, context.toOpaque() )) + // A submitted frame owns the original userdata until native free. + #expect(!ghostty_surface_set_render_presented_callback( + fixture.runtimeSurface, terminalRendererPresentedCallback, nil + )) + #expect(!ghostty_surface_set_render_failed_callback( + fixture.runtimeSurface, terminalRendererFailedCallback, nil + )) surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) #expect(surface.renderHealth == .awaitingFrame) @@ -35,7 +42,7 @@ import Testing } @Test func registeredFailureCallbackForwardsTokenAndTriggersOneRecoveryProbe() { - let fixture = PresentedSurfaceFixture() + let fixture = PresentedSurfaceFixture(configureRendererCallbacks: false) defer { fixture.tearDown() } let surface = fixture.surface let context = installCallbackContext(on: surface) @@ -49,6 +56,9 @@ import Testing terminalRendererFailedCallback, context.toOpaque() )) + #expect(!ghostty_surface_set_render_failed_callback( + fixture.runtimeSurface, terminalRendererFailedCallback, nil + )) surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) #expect(cmux_test_ghostty_renderer_fail( @@ -64,7 +74,7 @@ import Testing } @Test func shellExitHealthSurvivesRendererRebuildAndPresentation() { - let fixture = PresentedSurfaceFixture() + let fixture = PresentedSurfaceFixture(configureRendererCallbacks: false) defer { fixture.tearDown() } let surface = fixture.surface let context = installCallbackContext(on: surface) @@ -102,6 +112,17 @@ import Testing #expect(surface.renderHealth == .shellExited) } + @Test func disablingNativeCallbacksAlsoDisablesRendererRegistration() { + let fixture = PresentedSurfaceFixture(configureRendererCallbacks: false) + defer { fixture.tearDown() } + fixture.surface.installRuntimeSurfaceForTesting( + fixture.runtimeSurface, + configureNativeCallbacks: false, + configureRendererCallbacks: true + ) + #expect(!ghostty_surface_request_render_with_token(fixture.runtimeSurface, 1)) + } + private func installCallbackContext( on surface: TerminalSurface ) -> Unmanaged { diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift index c88912f1476a..40e8bb71f4f1 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift @@ -26,13 +26,9 @@ import Testing } private func failProbe(on surface: TerminalSurface) { - guard let token = surface.rendererPresentationState.inFlightToken else { - Issue.record("expected an in-flight presentation probe") - return - } - surface.rendererFrameDidFail( - token: token, + #expect(failPresentation( + on: surface, status: GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED - ) + )) } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift index 87723b36b94f..90bd4bbe0cf7 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift @@ -46,7 +46,7 @@ private func rendererReleaseWasOccluded() -> Bool registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) beginRendererRealizedTracking(runtimeSurface) surface.setRendererPortalVisible(true) - surface.installRuntimeSurfaceForTesting(runtimeSurface) + surface.installRuntimeSurfaceForTesting(runtimeSurface, configureRendererCallbacks: true) surface.rendererRuntimeSurfaceDidCreate() defer { surface.releaseSurfaceForTesting() @@ -77,7 +77,7 @@ private func rendererReleaseWasOccluded() -> Bool registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) beginRendererRealizedTracking(runtimeSurface) surface.setRendererPortalVisible(false, presentationReady: true) - surface.installRuntimeSurfaceForTesting(runtimeSurface) + surface.installRuntimeSurfaceForTesting(runtimeSurface, configureRendererCallbacks: true) surface.rendererRuntimeSurfaceDidCreate(presentationReady: false) defer { surface.releaseSurfaceForTesting() @@ -110,7 +110,7 @@ private func rendererReleaseWasOccluded() -> Bool registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) beginRendererRealizedTracking(runtimeSurface) surface.setRendererPortalVisible(false, presentationReady: true) - surface.installRuntimeSurfaceForTesting(runtimeSurface) + surface.installRuntimeSurfaceForTesting(runtimeSurface, configureRendererCallbacks: true) surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) defer { surface.releaseSurfaceForTesting() @@ -142,7 +142,7 @@ private func rendererReleaseWasOccluded() -> Bool registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) beginRendererRealizedTracking(runtimeSurface) surface.setRendererPortalVisible(false, presentationReady: true) - surface.installRuntimeSurfaceForTesting(runtimeSurface) + surface.installRuntimeSurfaceForTesting(runtimeSurface, configureRendererCallbacks: true) surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) defer { surface.releaseSurfaceForTesting() @@ -161,7 +161,7 @@ private func rendererReleaseWasOccluded() -> Bool registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) beginRendererRealizedTracking(runtimeSurface) surface.setRendererPortalVisible(true, presentationReady: true) - surface.installRuntimeSurfaceForTesting(runtimeSurface) + surface.installRuntimeSurfaceForTesting(runtimeSurface, configureRendererCallbacks: true) surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) acknowledgePresentation(on: surface) defer { @@ -199,7 +199,7 @@ private func rendererReleaseWasOccluded() -> Bool registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) beginRendererRealizedTracking(runtimeSurface) surface.setRendererPortalVisible(true, presentationReady: true) - surface.installRuntimeSurfaceForTesting(runtimeSurface) + surface.installRuntimeSurfaceForTesting(runtimeSurface, configureRendererCallbacks: true) surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) defer { surface.releaseSurfaceForTesting() @@ -217,10 +217,14 @@ private func rendererReleaseWasOccluded() -> Bool surface.setRendererPortalVisible(false, presentationReady: true) surface.setRendererPortalVisible(true, presentationReady: true) - let firstFailedToken = surface.rendererPresentationState.inFlightToken! - surface.rendererFrameDidFail(token: firstFailedToken, status: GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED) - let recoveryToken = surface.rendererPresentationState.inFlightToken! - surface.rendererFrameDidFail(token: recoveryToken, status: GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED) + #expect(failPresentation( + on: surface, + status: GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED + )) + #expect(failPresentation( + on: surface, + status: GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED + )) #expect(surface.renderHealth == .notRendering) } @@ -231,8 +235,9 @@ private func rendererReleaseWasOccluded() -> Bool registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) beginRendererRealizedTracking(runtimeSurface) surface.setRendererPortalVisible(true, presentationReady: true) - surface.installRuntimeSurfaceForTesting(runtimeSurface) + surface.installRuntimeSurfaceForTesting(runtimeSurface, configureRendererCallbacks: true) surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) + acknowledgePresentation(on: surface) defer { surface.releaseSurfaceForTesting() runtimeSurface.deallocate() @@ -264,7 +269,7 @@ private func rendererReleaseWasOccluded() -> Bool beginRendererRealizedTracking(runtimeSurface) setRendererRealizedResult(false) surface.setRendererPortalVisible(false, presentationReady: true) - surface.installRuntimeSurfaceForTesting(runtimeSurface) + surface.installRuntimeSurfaceForTesting(runtimeSurface, configureRendererCallbacks: true) surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) defer { surface.releaseSurfaceForTesting() @@ -316,7 +321,7 @@ private func rendererReleaseWasOccluded() -> Bool registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) beginRendererRealizedTracking(runtimeSurface) surface.setRendererPortalVisible(false, presentationReady: true) - surface.installRuntimeSurfaceForTesting(runtimeSurface) + surface.installRuntimeSurfaceForTesting(runtimeSurface, configureRendererCallbacks: true) surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) defer { surface.releaseSurfaceForTesting() @@ -363,7 +368,7 @@ private func rendererReleaseWasOccluded() -> Bool registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) beginRendererRealizedTracking(runtimeSurface) surface.setRendererPortalVisible(false, presentationReady: true) - surface.installRuntimeSurfaceForTesting(runtimeSurface) + surface.installRuntimeSurfaceForTesting(runtimeSurface, configureRendererCallbacks: true) surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) defer { surface.releaseSurfaceForTesting() @@ -393,7 +398,7 @@ private func rendererReleaseWasOccluded() -> Bool registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) beginRendererRealizedTracking(runtimeSurface) surface.setRendererPortalVisible(false, presentationReady: true) - surface.installRuntimeSurfaceForTesting(runtimeSurface) + surface.installRuntimeSurfaceForTesting(runtimeSurface, configureRendererCallbacks: true) surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) defer { surface.releaseSurfaceForTesting() @@ -432,29 +437,6 @@ private func rendererReleaseWasOccluded() -> Bool (0.. Unmanaged { - let callbackContext = Unmanaged.passRetained(GhosttySurfaceCallbackContext( - surfaceHost: surface.surfaceView, - surfaceController: surface, - terminalLifecycleID: surface.terminalLifecycleId, - rendererMailboxDidDrain: { surfaceID in - MainActor.assumeIsolated { - scheduler.scheduleRendererPresentationRepair(surfaceID: surfaceID) - } - } - )) - surface.surfaceCallbackContext = callbackContext - return callbackContext - } - private func makeSurface( registry: TerminalSurfaceRegistry, rendererRealization: any TerminalRendererRealizationScheduling = FakeRendererRealizationScheduler() diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift index a04174945e65..03cb78ce7d49 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceTeardownCallbackLifetimeTests.swift @@ -162,6 +162,45 @@ import Testing #expect(recorder.events == [.nativeFree, .teeLeaseRelease]) } + @Test func agentHibernationWithoutSurfaceReleasesRetainedRuntimeResources() async { + let recorder = TeardownOrderRecorder() + let surface = makeSurface() + weak var weakCallbackContext: GhosttySurfaceCallbackContext? + weak var weakManualIOContext: TerminalManualIOWriteBox? + do { + let callbackContext = GhosttySurfaceCallbackContext( + surfaceHost: surface.surfaceView, + surfaceController: surface, + terminalLifecycleID: surface.terminalLifecycleId + ) + weakCallbackContext = callbackContext + surface.surfaceCallbackContext = Unmanaged.passRetained(callbackContext) + let manualIOContext = TerminalManualIOWriteBox(onWrite: { _ in }) + weakManualIOContext = manualIOContext + surface.manualIOContext = Unmanaged.passRetained(manualIOContext) + } + surface.mobileByteTeeLease = RecordingTerminalByteTeeLease(recorder: recorder) + surface.mobileViewportFontFitState = MobileViewportFontFitState( + baseRuntimePointSize: 12, + fittedRuntimePointSize: 8 + ) + + #expect(surface.surface == nil) + #expect(surface.suspendRuntimeSurfaceForAgentHibernation(reason: "test.nilSurface")) + #expect(surface.surfaceCallbackContext == nil) + #expect(surface.manualIOContext == nil) + #expect(surface.mobileByteTeeLease == nil) + #expect(surface.mobileViewportFontFitState == nil) + + #expect( + await recorder.waitForEventCount(1), + "nil-surface hibernation did not drain and release retained runtime resources" + ) + #expect(recorder.events == [.teeLeaseRelease]) + #expect(weakCallbackContext == nil) + #expect(weakManualIOContext == nil) + } + @Test func agentHibernationEndsCurrentTerminalProcessGeneration() { let registry = TerminalSurfaceRegistry() let surface = makeSurface(registry: registry) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index f8671a3e935a..8b09eb098afc 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -259645,6 +259645,48 @@ "state": "translated", "value": "この Mac では iOS ペアリングがオフです。設定を開き、iOS ペアリングを有効にすると、iPhone の cmux からこの Mac を検出できます。" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die iOS-Kopplung ist auf diesem Mac deaktiviert. Öffnen Sie die Einstellungen und aktivieren Sie die iOS-Kopplung, damit cmux auf Ihrem iPhone diesen Mac finden kann." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le jumelage iOS est désactivé sur ce Mac. Ouvrez les réglages et activez le jumelage iOS pour que cmux sur votre iPhone puisse détecter ce Mac." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إقران iOS متوقف على هذا الـ Mac. افتح الإعدادات وفعّل إقران iOS ليتمكن cmux على iPhone من اكتشاف هذا الـ Mac." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El enlace con iOS está desactivado en este Mac. Abre Ajustes y activa el enlace con iOS para que cmux en tu iPhone pueda detectar este Mac." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此 Mac 已關閉 iOS 配對。請開啟設定並啟用 iOS 配對,讓 iPhone 上的 cmux 能夠找到此 Mac。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此 Mac 已关闭 iOS 配对。请打开设置并启用 iOS 配对,让 iPhone 上的 cmux 能够发现此 Mac。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 Mac에서는 iOS 페어링이 꺼져 있습니다. 설정을 열고 iOS 페어링을 켜면 iPhone의 cmux에서 이 Mac을 찾을 수 있습니다." + } } } }, @@ -259662,6 +259704,48 @@ "state": "translated", "value": "設定を開く" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen öffnen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir les réglages" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الإعدادات" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir Ajustes" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟設定" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开设置" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정 열기" + } } } }, @@ -259679,6 +259763,48 @@ "state": "translated", "value": "iOS ペアリングを有効にする" } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "iOS-Kopplung aktivieren" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer le jumelage iOS" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تفعيل إقران iOS" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activar el enlace con iOS" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟用 iOS 配對" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用 iOS 配对" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "iOS 페어링 켜기" + } } } }, diff --git a/Sources/AgentForkSupport.swift b/Sources/AgentForkSupport.swift index 9c7d2316b9f4..850f9df6d2c9 100644 --- a/Sources/AgentForkSupport.swift +++ b/Sources/AgentForkSupport.swift @@ -199,8 +199,8 @@ enum AgentForkSupport { private static func forkCommandIdentityParts(snapshot: SessionRestorableAgentSnapshot) -> [String]? { guard snapshot.kind.restoreMode == .resumeSession, - forkCommandCanRenderWithoutFilesystem(snapshot), - normalized(snapshot.sessionId) != nil else { + snapshot.effectiveRestoreWorkingDirectorySelection(.recordedFallback(preferred: nil)).permitsResume, + forkCommandCanRenderWithoutFilesystem(snapshot), normalized(snapshot.sessionId) != nil else { return nil } diff --git a/Sources/AgentRelaunchCommandBuilder.swift b/Sources/AgentRelaunchCommandBuilder.swift index 970eebbbbf8c..31cb72f044aa 100644 --- a/Sources/AgentRelaunchCommandBuilder.swift +++ b/Sources/AgentRelaunchCommandBuilder.swift @@ -12,6 +12,21 @@ struct AgentRelaunchCommandBuilder { launchCommand: AgentLaunchCommandSnapshot?, workingDirectory: String?, includeWorkingDirectoryPrefix: Bool = true + ) -> String? { + shellCommand( + kind: kind, + launchCommand: launchCommand, + resolvedWorkingDirectory: workingDirectory ?? launchCommand?.workingDirectory, + includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix + ) + } + + /// Builds a relaunch command after the caller has applied its cwd fallback policy. + func shellCommand( + kind: RestorableAgentKind, + launchCommand: AgentLaunchCommandSnapshot?, + resolvedWorkingDirectory: String?, + includeWorkingDirectoryPrefix: Bool = true ) -> String? { guard kind.restoreMode == .relaunchCommand, let launchCommand, @@ -43,7 +58,7 @@ struct AgentRelaunchCommandBuilder { guard includeWorkingDirectoryPrefix else { return command } return TerminalStartupWorkingDirectoryPrefix.prefix( command, - workingDirectory: workingDirectory ?? launchCommand.workingDirectory + workingDirectory: resolvedWorkingDirectory ) } } diff --git a/Sources/ControlSurfaceResumeTarget+BindingSanitization.swift b/Sources/ControlSurfaceResumeTarget+BindingSanitization.swift new file mode 100644 index 000000000000..fc20f1caf629 --- /dev/null +++ b/Sources/ControlSurfaceResumeTarget+BindingSanitization.swift @@ -0,0 +1,40 @@ +import Foundation +import CMUXAgentLaunch + +extension ControlSurfaceResumeTarget { + /// Resolves the built-in cwd-option identity available to a binding-only restore. + /// + /// Registry-owned spellings such as `kimi` may identify either a native + /// agent or a user Vault registration. A matching native snapshot is the + /// only safe evidence for enabling its provider-specific short option; + /// otherwise only non-overridable built-ins are unambiguous. + func builtInAgentKindForBindingSanitization( + binding: SurfaceResumeBindingSnapshot, + normalizedKind: String + ) -> String? { + let normalized = normalizedKind + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if let snapshot = restorableAgent, + snapshot.kind.rawValue + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() == normalized, + let checkpointID = binding.checkpointId? + .trimmingCharacters(in: .whitespacesAndNewlines), + !checkpointID.isEmpty, + ManagedAgentSessionIdentity.sessionIDsMatch( + kind: normalized, + lhs: checkpointID, + rhs: snapshot.sessionId + ), + let snapshotBuiltInKind = snapshot.workingDirectoryOptionPolicyBuiltInKind { + return snapshotBuiltInKind + } + // Without a matching native snapshot, the persisted kind may be a + // custom Vault registration reusing a built-in spelling. Do not infer + // provider-specific cwd flags from the raw binding id; preserving an + // ambiguous option is safer than stripping a custom profile selector. + return nil + } + +} diff --git a/Sources/ControlSurfaceResumeTarget+ContinuationRecord.swift b/Sources/ControlSurfaceResumeTarget+ContinuationRecord.swift index 4afbabcea1e1..d34f3cf0ed2f 100644 --- a/Sources/ControlSurfaceResumeTarget+ContinuationRecord.swift +++ b/Sources/ControlSurfaceResumeTarget+ContinuationRecord.swift @@ -10,39 +10,65 @@ extension TerminalController { restoredWorkingDirectory: String?, binding: SurfaceResumeBindingSnapshot?, compatibilityBinding: SurfaceResumeBindingSnapshot? - ) -> ControlSurfaceRestoreRecord { - let launchCommand = binding?.launchCommand ?? agent.launchCommand - let workingDirectory = restoredWorkingDirectory - ?? binding?.cwd - ?? agent.workingDirectory - ?? launchCommand?.workingDirectory + ) -> ControlSurfaceRestoreRecord? { + guard binding?.restoreWorkingDirectorySelection?.permitsResume != false else { + return nil + } + let bindingScopedAgent: SessionRestorableAgentSnapshot = if let bindingSelection = + binding?.restoreWorkingDirectorySelection, + bindingSelection.discardsRecordedCwdOptions { + agent.applyingAuthoritativeBindingSelection(bindingSelection) + } else { + agent + } + let workingDirectorySelection = bindingScopedAgent.effectiveRestoreWorkingDirectorySelection( + .recordedFallback(preferred: restoredWorkingDirectory ?? binding?.cwd) + ) + guard workingDirectorySelection.permitsResume else { return nil } + let launchCommand = bindingScopedAgent.constrainedLaunchCommand( + binding?.launchCommand ?? bindingScopedAgent.launchCommand, + selection: workingDirectorySelection + ) + let workingDirectory = workingDirectorySelection.resolved( + snapshotWorkingDirectory: bindingScopedAgent.workingDirectory, + launchWorkingDirectory: launchCommand?.workingDirectory + ) let permissionMode = binding?.permissionMode ?? agent.permissionMode - let mode: AgentRestoreRequestMode = agent.kind.restoreMode == .relaunchCommand + let mode: AgentRestoreRequestMode = bindingScopedAgent.kind.restoreMode == .relaunchCommand ? .relaunchAgent : .resumeAgent - let preparedArguments = agent.kind.restoreMode == .resumeSession - ? agent.preparedResumeArguments( + let preparedArguments = bindingScopedAgent.kind.restoreMode == .resumeSession + ? bindingScopedAgent.preparedResumeArguments( launchCommand: launchCommand, - workingDirectory: workingDirectory, + workingDirectorySelection: workingDirectorySelection, observedPermissionMode: permissionMode ) : nil - let forkArguments = agent.preparedForkArguments( + let legacyCommand = binding?.restoreWorkingDirectorySelection?.discardsRecordedCwdOptions != true && + bindingScopedAgent.restoreWorkingDirectorySelection?.discardsRecordedCwdOptions != true + ? compatibilityBinding?.inlineStartupInput + : nil + guard bindingScopedAgent.kind.customAgentID == nil || + preparedArguments?.isEmpty == false || + legacyCommand != nil else { + return nil + } + let forkArguments = bindingScopedAgent.preparedForkArguments( launchCommand: launchCommand, workingDirectory: workingDirectory, observedPermissionMode: permissionMode ) return ControlSurfaceRestoreRecord( modeRawValue: mode.rawValue, - kind: agent.kind.rawValue, - checkpointID: agent.sessionId, + kind: bindingScopedAgent.kind.rawValue, + checkpointID: bindingScopedAgent.sessionId, source: source, workingDirectory: workingDirectory, environment: binding?.environment ?? [:], launchCommand: launchCommand.map { controlAgentLaunchCommand( $0, - replaySafeEnvironmentFor: agent.kind.rawValue + replaySafeEnvironmentFor: bindingScopedAgent.kind.rawValue ) }, preparedArguments: preparedArguments, @@ -50,10 +76,10 @@ extension TerminalController { ? nil : workingDirectory, permissionMode: permissionMode, - legacyCommand: compatibilityBinding?.inlineStartupInput, + legacyCommand: legacyCommand, forkArguments: forkArguments, forkArgumentsWorkingDirectory: forkArguments == nil ? nil : workingDirectory, - legacyForkCommand: agent.forkCommand( + legacyForkCommand: bindingScopedAgent.forkCommand( restoringWorkingDirectory: workingDirectory ) ) @@ -61,12 +87,19 @@ extension TerminalController { /// Builds a continuation record after a live binding supersedes a snapshot. func controlSurfaceBindingContinuationRecord( + target: ControlSurfaceResumeTarget, binding: SurfaceResumeBindingSnapshot, compatibilityBinding: SurfaceResumeBindingSnapshot?, restoredAgentExists: Bool - ) -> ControlSurfaceRestoreRecord { + ) -> ControlSurfaceRestoreRecord? { let trimmedKind = binding.kind?.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedKind = trimmedKind.flatMap { $0.isEmpty ? nil : $0 } ?? "command" + let bindingSelection = binding.restoreWorkingDirectorySelection + let isUnscopedCustomAgentHook = binding.isAgentHookBinding && + bindingSelection == nil && + RestorableAgentKind(rawValue: normalizedKind)?.customAgentID != nil + guard !isUnscopedCustomAgentHook else { return nil } + guard bindingSelection?.permitsResume != false else { return nil } let mode: AgentRestoreRequestMode if let kind = RestorableAgentKind(rawValue: normalizedKind), kind.restoreMode == .relaunchCommand { @@ -76,10 +109,39 @@ extension TerminalController { } // A superseded snapshot cannot authorize its registry template. Rebuild // only native argv from the binding that now owns the surface. - let workingDirectory = binding.cwd ?? binding.launchCommand?.workingDirectory + let workingDirectory: String? = if let bindingSelection { + bindingSelection.resolved( + snapshotWorkingDirectory: binding.cwd, + launchWorkingDirectory: binding.launchCommand?.workingDirectory + ) + } else { + target.restoredResumeWorkingDirectory + ?? binding.cwd + ?? binding.launchCommand?.workingDirectory + } + let launchCommand: AgentLaunchCommandSnapshot? + if let bindingSelection, + bindingSelection.discardsRecordedCwdOptions, + var command = binding.launchCommand { + let builtInAgentKind = target.builtInAgentKindForBindingSanitization( + binding: binding, + normalizedKind: normalizedKind + ) + command.arguments = AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: command.arguments, + workingDirectory: nil, + agentKind: builtInAgentKind, + removeAllWorkingDirectoryOptions: true + ) + command.workingDirectory = nil + launchCommand = command + } else { + launchCommand = binding.launchCommand + } let preparedArguments = restoredAgentExists ? preparedResumeArguments( binding: binding, + launchCommand: launchCommand, normalizedKind: normalizedKind, workingDirectory: workingDirectory ) @@ -87,10 +149,20 @@ extension TerminalController { let forkArguments = restoredAgentExists ? preparedForkArguments( binding: binding, + launchCommand: launchCommand, normalizedKind: normalizedKind, workingDirectory: workingDirectory ) : nil + let legacyCommand = bindingSelection?.discardsRecordedCwdOptions == true + ? nil + : compatibilityBinding?.inlineStartupInput + guard !binding.isAgentHookBinding || + RestorableAgentKind(rawValue: normalizedKind)?.customAgentID == nil || + preparedArguments?.isEmpty == false || + legacyCommand != nil else { + return nil + } return ControlSurfaceRestoreRecord( modeRawValue: mode.rawValue, kind: normalizedKind, @@ -98,20 +170,20 @@ extension TerminalController { source: binding.source, workingDirectory: workingDirectory, environment: binding.environment ?? [:], - launchCommand: binding.launchCommand.map { + launchCommand: launchCommand.map { controlAgentLaunchCommand( $0, replaySafeEnvironmentFor: normalizedKind ) }, preparedArguments: mode == .direct - ? binding.launchCommand?.arguments + ? launchCommand?.arguments : preparedArguments, preparedArgumentsWorkingDirectory: preparedArguments == nil ? nil : workingDirectory, permissionMode: binding.permissionMode, - legacyCommand: compatibilityBinding?.inlineStartupInput, + legacyCommand: legacyCommand, forkArguments: forkArguments, forkArgumentsWorkingDirectory: forkArguments == nil ? nil : workingDirectory, legacyForkCommand: nil @@ -120,6 +192,7 @@ extension TerminalController { private func preparedResumeArguments( binding: SurfaceResumeBindingSnapshot, + launchCommand: AgentLaunchCommandSnapshot?, normalizedKind: String, workingDirectory: String? ) -> [String]? { @@ -139,10 +212,10 @@ extension TerminalController { kind: kind, sessionId: checkpointID, workingDirectory: workingDirectory, - launchCommand: binding.launchCommand, + launchCommand: launchCommand, permissionMode: binding.permissionMode ).preparedResumeArguments( - launchCommand: binding.launchCommand, + launchCommand: launchCommand, workingDirectory: workingDirectory, observedPermissionMode: binding.permissionMode ) @@ -150,6 +223,7 @@ extension TerminalController { private func preparedForkArguments( binding: SurfaceResumeBindingSnapshot, + launchCommand: AgentLaunchCommandSnapshot?, normalizedKind: String, workingDirectory: String? ) -> [String]? { @@ -165,10 +239,10 @@ extension TerminalController { kind: kind, sessionId: checkpointID, workingDirectory: workingDirectory, - launchCommand: binding.launchCommand, + launchCommand: launchCommand, permissionMode: binding.permissionMode ).preparedForkArguments( - launchCommand: binding.launchCommand, + launchCommand: launchCommand, workingDirectory: workingDirectory, observedPermissionMode: binding.permissionMode ) diff --git a/Sources/ControlSurfaceResumeTarget.swift b/Sources/ControlSurfaceResumeTarget.swift index 5ccc8efc7e7d..166387916ed0 100644 --- a/Sources/ControlSurfaceResumeTarget.swift +++ b/Sources/ControlSurfaceResumeTarget.swift @@ -148,7 +148,10 @@ enum ControlSurfaceResumeTarget { let context = workspace.persistentSSHResumeContext(panelID: surfaceID) else { return nil } - return binding.registeredForPersistentSSH(context) + return binding.registeredForPersistentSSH( + context, + restorableAgent: self.restorableAgent + ) case .dock(_, let dock, let surfaceID): guard let registration = dock.persistentSSHResumeRegistration(panelId: surfaceID), remoteWorkspaceID == registration.context.workspaceID, @@ -158,7 +161,10 @@ enum ControlSurfaceResumeTarget { ) else { return nil } - return binding.registeredForPersistentSSH(registration.context) + return binding.registeredForPersistentSSH( + registration.context, + restorableAgent: self.restorableAgent + ) } } } diff --git a/Sources/DockSplitStore+AgentResumeOwnership.swift b/Sources/DockSplitStore+AgentResumeOwnership.swift index 3b20e0211df1..16009e91aa6c 100644 --- a/Sources/DockSplitStore+AgentResumeOwnership.swift +++ b/Sources/DockSplitStore+AgentResumeOwnership.swift @@ -13,7 +13,14 @@ extension DockSplitStore { let binding = surfaceResumeBindingsByPanelId[panelId] ?? managedAgentResumeBindingsByPanelId[panelId] guard let agent = restoredAgent - ?? binding.flatMap({ $0.isAgentHookBinding ? $0.managedRestorableAgentSnapshot(replacing: nil) : nil }) + ?? binding.flatMap({ + $0.isAgentHookBinding + ? $0.managedRestorableAgentSnapshot( + replacing: nil, + previousBinding: nil + ) + : nil + }) else { return false } diff --git a/Sources/DockSplitStore+RestoredAgentLifecycle.swift b/Sources/DockSplitStore+RestoredAgentLifecycle.swift index c8dacc672ca7..fc96c8201f7b 100644 --- a/Sources/DockSplitStore+RestoredAgentLifecycle.swift +++ b/Sources/DockSplitStore+RestoredAgentLifecycle.swift @@ -69,7 +69,6 @@ extension DockSplitStore { break } } - /// Starts title admission for a terminal rebuilt directly inside this Dock. func armRestoredPanelTitleBoundary( panelId: UUID, @@ -186,7 +185,6 @@ extension DockSplitStore { return self.resumeAgentHibernation(panelId: terminal.id, focus: focus) } } - @discardableResult func resumeAgentHibernation(panelId: UUID, focus: Bool) -> Bool { guard let terminal = panels[panelId] as? TerminalPanel, @@ -249,7 +247,6 @@ extension DockSplitStore { } } } - func agentRuntimeStatusEntry(key: String, panelId: UUID) -> SidebarStatusEntry? { agentRuntimeByPanelId[panelId]?.statusEntries[key] } @@ -263,7 +260,6 @@ extension DockSplitStore { $0.statusEntries[key] = entry } } - func clearAgentRuntimeStatusEntry(key: String, panelId: UUID) { mutateAgentRuntime(panelId: panelId) { $0.statusEntries.removeValue(forKey: key) @@ -468,7 +464,6 @@ extension DockSplitStore { } } } - private func resolveDeferredAgentResumeRestores( using index: RestorableAgentSessionIndex ) { @@ -588,15 +583,11 @@ extension DockSplitStore { cancelDeferredAgentResumeRestore(panelId: panelId, restore: restore) continue } - let startupInput: String? let claim: (kind: String, sessionId: String)? if let restorableAgent = restore.restorableAgent { startupInput = if restore.restoresRemoteWorkspaceTerminalSnapshot { - restorableAgent.resumeStartupInput( - useLocalRestoreVerb: false, - restoringWorkingDirectory: restore.resumeWorkingDirectory - ) + restorableAgent.remoteResumeStartupInput() } else { restorableAgent.resumeStartupInput( restoringWorkingDirectory: restore.resumeWorkingDirectory @@ -605,7 +596,8 @@ extension DockSplitStore { claim = (restorableAgent.kind.rawValue, restorableAgent.sessionId) } else if let binding = currentResumeBinding ?? restore.resumeBinding { if restore.restoresRemoteWorkspaceTerminalSnapshot { - guard binding.launchFlavor.remoteContext == restore.remoteResumeContext else { + guard binding.launchFlavor.remoteContext == restore.remoteResumeContext, + !binding.isAgentHookBinding || binding.hasExactRestoreWorkingDirectorySelection else { cancelDeferredAgentResumeRestore(panelId: panelId, restore: restore) continue } @@ -618,9 +610,14 @@ extension DockSplitStore { promptForApproval: true, approvalStoreURL: SurfaceResumeApprovalStore.defaultURL() ) + let matchingRestorableAgent = restoredAgentLifecycle.snapshotsByPanelId[panelId].flatMap { + Workspace.restorableAgentForSessionRestore($0, resumeBinding: binding) + } startupInput = approvedBinding.flatMap { if restore.restoresRemoteWorkspaceTerminalSnapshot { - return $0.remoteStartupInput() + return $0.remoteStartupInput( + registration: matchingRestorableAgent?.registration + ) } return policy.surfaceResumeStartupLaunch(forApprovedBinding: $0)?.initialInput } @@ -700,7 +697,9 @@ extension DockSplitStore { } } } - +#if DEBUG + func resolveDeferredAgentResumeRestoresForTesting(using index: RestorableAgentSessionIndex) { resolveDeferredAgentResumeRestores(using: index) } +#endif /// Builds a transfer-scoped persistent-SSH attach that prints the live-owner /// notice without replaying the embedded agent command. private func detachedRemoteLiveOwnerNoticeAttachCommand( diff --git a/Sources/DockSplitStore+SurfaceResume.swift b/Sources/DockSplitStore+SurfaceResume.swift index a2a241b8aaea..50a12298ef73 100644 --- a/Sources/DockSplitStore+SurfaceResume.swift +++ b/Sources/DockSplitStore+SurfaceResume.swift @@ -3,9 +3,12 @@ import Foundation extension DockSplitStore { @discardableResult func setSurfaceResumeBinding(_ binding: SurfaceResumeBindingSnapshot, panelId: UUID) -> Bool { - guard panels[panelId] is TerminalPanel, - let startupInput = binding.inlineStartupInput(repairPortableAgentExecutable: false), - !startupInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + guard panels[panelId] is TerminalPanel else { + return false + } + let startupInput = binding.inlineStartupInput(repairPortableAgentExecutable: false) + guard startupInput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false || + binding.permitsTransportOnlyPersistentSSHRestore else { return false } let activeRestoreClaim = surfaceResumeRestoreClaim(for: panelId) @@ -82,8 +85,68 @@ extension DockSplitStore { preserveCompletedTombstone: false ) } - if binding.hasCompleteManagedSessionIdentity { - managedAgentResumeBindingsByPanelId[panelId] = binding + let constrainedBinding: SurfaceResumeBindingSnapshot = { + // A persistent-SSH selection is authenticated remote authority. Carry + // it through the Dock binding itself so the retained snapshot and + // every later Dock restore path use the same cwd-safe launch recipe + // as workspace restores. + if binding.isAgentHookBinding, + binding.launchFlavor.remoteContext != nil, + let selection = binding.restoreWorkingDirectorySelection, + selection.discardsRecordedCwdOptions, + let previousRestorableAgent, + Workspace.restorableAgentForSessionRestore( + previousRestorableAgent, + resumeBinding: binding + ) != nil { + return binding.applyingAuthoritativeRemoteRestoreWorkingDirectorySelection( + selection, + from: previousRestorableAgent + ) + } + + // A fresh authenticated remote report is authoritative even when + // the retained snapshot was cleared or cannot be matched. Do not + // let the prior binding's cwd policy overwrite the new selection. + if binding.isAgentHookBinding, + binding.launchFlavor.remoteContext != nil, + binding.restoreWorkingDirectorySelection != nil { + return binding + } + + // Same-session refreshes can omit the cwd policy. Preserve the + // retained, already-constrained snapshot while the execution + // location remains the same; local refreshes must never inherit a + // remote-only cwd policy. + if binding.isAgentHookBinding, + effectivePreviousBinding?.launchFlavor.representsSameExecutionLocation( + as: binding.launchFlavor + ) == true, + let previousRestorableAgent, + let selection = previousRestorableAgent.restoreWorkingDirectorySelection, + Workspace.restorableAgentForSessionRestore( + previousRestorableAgent, + resumeBinding: binding + ) != nil { + return binding.applyingRestoreWorkingDirectorySelection( + selection, + from: previousRestorableAgent + ) + } + + if let effectivePreviousBinding, + effectivePreviousBinding.isSameManagedSession(as: binding), + effectivePreviousBinding.launchFlavor.representsSameExecutionLocation( + as: binding.launchFlavor + ) { + return binding.inheritingRestoreWorkingDirectorySelection( + from: effectivePreviousBinding + ) + } + return binding + }() + if constrainedBinding.hasCompleteManagedSessionIdentity { + managedAgentResumeBindingsByPanelId[panelId] = constrainedBinding } else if binding.isAgentHookBinding { managedAgentResumeBindingsByPanelId.removeValue(forKey: panelId) } @@ -91,21 +154,22 @@ extension DockSplitStore { // same-session hook refresh keep its cwd rescue, but never let it // override a replacement session's structured restore record. if let previous = effectivePreviousBinding, - previous.kind != binding.kind - || previous.checkpointId != binding.checkpointId - || previous.cwd != binding.cwd - || previous.launchCommand?.workingDirectory != binding.launchCommand?.workingDirectory - || (previous.launchCommand == nil && binding.launchCommand == nil - && previous.command != binding.command) { + previous.kind != constrainedBinding.kind + || previous.checkpointId != constrainedBinding.checkpointId + || previous.cwd != constrainedBinding.cwd + || previous.launchCommand?.workingDirectory != constrainedBinding.launchCommand?.workingDirectory + || (previous.launchCommand == nil && constrainedBinding.launchCommand == nil + && previous.command != constrainedBinding.command) { restoredResumeSessionWorkingDirectoriesByPanelId.removeValue(forKey: panelId) } - if let restorableAgent = binding.managedRestorableAgentSnapshot( - replacing: previousRestorableAgent + if let restorableAgent = constrainedBinding.managedRestorableAgentSnapshot( + replacing: previousRestorableAgent, + previousBinding: effectivePreviousBinding ) { restoredAgentLifecycle.setSnapshot(restorableAgent, panelId: panelId) restoredAgentLifecycle.invalidatedFingerprintsByPanelId.removeValue(forKey: panelId) } - surfaceResumeBindingsByPanelId[panelId] = binding + surfaceResumeBindingsByPanelId[panelId] = constrainedBinding return true } @@ -177,7 +241,8 @@ extension DockSplitStore { guard let currentBinding = surfaceResumeBindingsByPanelId[panelId], currentBinding.checkpointId == claim.binding.checkpointId, currentBinding.source == claim.binding.source, - currentBinding.updatedAt == claim.binding.updatedAt else { + currentBinding.updatedAt == claim.binding.updatedAt, + currentBinding.launchFlavor == claim.binding.launchFlavor else { // A direct lifecycle mutation replaced the claimed generation // without going through the hook setter. Do not let that old claim // block a later, legitimate binding. diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index c84ef3b34236..946b38b78ce2 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -1945,8 +1945,8 @@ extension FileExplorerContainerView: NSSearchFieldDelegate, NSTableViewDataSourc func tableView( _ tableView: NSTableView, draggingSession session: NSDraggingSession, - endedAt screenPoint: NSPoint, - operation: NSDragOperation + endedAt _: NSPoint, + operation _: NSDragOperation ) { guard tableView === searchResultsView else { return } if searchResultsView.activeNativeDragSession === session { diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 133e37100afc..0f765efaadd7 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -117,7 +117,8 @@ enum TerminalStartupWorkingDirectoryPrefix { static func replacingRequiredChangeDirectoryPrefix( in command: String, - workingDirectory: String? + workingDirectory: String?, + agentKind: String? = nil ) -> String { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard let workingDirectory = normalized(workingDirectory) else { return trimmed } @@ -127,7 +128,8 @@ enum TerminalStartupWorkingDirectoryPrefix { ) let command = strippedSavedWorkingDirectoryOptions( from: stripped, - workingDirectory: workingDirectory + workingDirectory: workingDirectory, + agentKind: agentKind ) return prefix(command, workingDirectory: workingDirectory) } @@ -135,18 +137,21 @@ enum TerminalStartupWorkingDirectoryPrefix { static func replacingRequiredChangeDirectoryPrefix( in command: String, previousWorkingDirectory: String?, - workingDirectory: String? + workingDirectory: String?, + agentKind: String? = nil ) -> String { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) let stripped = normalized(previousWorkingDirectory).map { strippedSavedWorkingDirectoryOptions( from: strippedRequiredChangeDirectoryPrefix(from: trimmed, workingDirectory: $0), - workingDirectory: $0 + workingDirectory: $0, + agentKind: agentKind ) } ?? trimmed return replacingRequiredChangeDirectoryPrefix( in: stripped, - workingDirectory: workingDirectory + workingDirectory: workingDirectory, + agentKind: agentKind ) } @@ -188,12 +193,14 @@ enum TerminalStartupWorkingDirectoryPrefix { private static func strippedSavedWorkingDirectoryOptions( from command: String, - workingDirectory: String + workingDirectory: String, + agentKind: String? ) -> String { let words = shellWordRanges(command) let ranges = savedWorkingDirectoryOptionRanges( in: words, - workingDirectory: workingDirectory + workingDirectory: workingDirectory, + agentKind: agentKind ) guard !ranges.isEmpty else { return command } return removingRanges(removing: ranges, from: command) @@ -287,39 +294,6 @@ enum TerminalStartupWorkingDirectoryPrefix { return words } - private static func savedWorkingDirectoryOptionRanges( - in words: [ShellWordRange], - workingDirectory: String - ) -> [Range] { - let valueOptions: Set = ["--cd", "-C", "--cwd", "--workspace", "-w"] - let optionPrefixes = valueOptions.map { "\($0)=" } - var ranges: [Range] = [] - var index = 0 - while index < words.count { - let arg = words[index].value - if arg == "--" { - break - } - if valueOptions.contains(arg), - index + 1 < words.count, - workingDirectoryValue(words[index + 1].value, matches: workingDirectory) { - ranges.append(words[index].range.lowerBound..], from command: String @@ -365,12 +339,6 @@ enum TerminalStartupWorkingDirectoryPrefix { return result.trimmingCharacters(in: .whitespacesAndNewlines) } - private static func workingDirectoryValue(_ value: String, matches workingDirectory: String) -> Bool { - guard value == workingDirectory else { - return (value as NSString).expandingTildeInPath == (workingDirectory as NSString).expandingTildeInPath - } - return true - } } enum AgentResumeCommandBuilder { @@ -481,7 +449,7 @@ enum AgentResumeCommandBuilder { ? workingDirectoriesToRemove.reduce(commandParts) { parts, directory in AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( from: parts, - workingDirectory: directory + workingDirectory: directory, agentKind: kind.rawValue ) } : commandParts @@ -596,7 +564,7 @@ enum AgentResumeCommandBuilder { return kind.rawValue } - fileprivate static func resumeArguments( + static func resumeArguments( kind: RestorableAgentKind, sessionId: String, launchCommand: AgentLaunchCommandSnapshot?, @@ -767,25 +735,13 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { /// Last hook-observed permission mode; re-applied as `--permission-mode` on /// user-owned claude resume/fork when no explicit launch flag covers it. var permissionMode: String? = nil - - func preparedResumeArguments( - launchCommand: AgentLaunchCommandSnapshot?, - workingDirectory: String?, - observedPermissionMode: String? - ) -> [String]? { - AgentResumeCommandBuilder.resumeArguments( - kind: kind, - sessionId: sessionId, - launchCommand: launchCommand, - workingDirectory: workingDirectory, - customRegistration: registration, - observedPermissionMode: observedPermissionMode - ) - } + /// Persisted cwd trust boundary applied to every later restore entrypoint. + var restoreWorkingDirectorySelection: AgentRestoreWorkingDirectorySelection? = nil func resumeStartupInput( useLocalRestoreVerb: Bool = true, - restoringWorkingDirectory: String? = nil + restoringWorkingDirectory: String? = nil, + restoringWorkingDirectorySelection: AgentRestoreWorkingDirectorySelection? = nil ) -> String? { if useLocalRestoreVerb { let executable = AgentRestoreLaunch.cliStartupExecutableToken @@ -795,12 +751,11 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { } return " \(executable) restore \(kind.rawValue) \(sessionId)\n" } - let effectiveWorkingDirectory = resumeWorkingDirectory( - preferred: restoringWorkingDirectory - ) + let selection = restoringWorkingDirectorySelection + ?? .recordedFallback(preferred: restoringWorkingDirectory) let restoreCommand = resumeCommand( includeWorkingDirectoryPrefix: true, - restoringWorkingDirectory: effectiveWorkingDirectory + workingDirectorySelection: selection ).map { command in AgentRestoreLaunch(kind: kind.rawValue, sessionID: sessionId)? .applying(toStoredCommand: command) ?? command @@ -808,6 +763,15 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { return restoreCommand.map { $0 + "\n" } } + /// Renders a remote resume without allowing captured local cwd values to + /// stand in for an authenticated remote selection. + func remoteResumeStartupInput() -> String? { + resumeStartupInput( + useLocalRestoreVerb: false, + restoringWorkingDirectorySelection: restoreWorkingDirectorySelection ?? .unavailable + ) + } + /// Input that forks this agent conversation when typed into a shell. /// `dialect` must match the shell that will parse it — `.remoteHost` for /// remote forks. diff --git a/Sources/RestoredAgentLifecycleCoordinator.swift b/Sources/RestoredAgentLifecycleCoordinator.swift index 266a535f5e7c..88a22a8a7d57 100644 --- a/Sources/RestoredAgentLifecycleCoordinator.swift +++ b/Sources/RestoredAgentLifecycleCoordinator.swift @@ -13,6 +13,8 @@ final class RestoredAgentLifecycleCoordinator { self.dateProvider = dateProvider } + /// Current restored snapshots. All mutations flow through lifecycle methods + /// so queued restore identity cannot drift from the mutable snapshot map. private(set) var snapshotsByPanelId: [UUID: SessionRestorableAgentSnapshot] = [:] /// Immutable session target retained until the staged startup command completes. private var queuedRestoreSnapshotsByPanelId: [UUID: SessionRestorableAgentSnapshot] = [:] diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 4e8129d0348e..70767b4c4c4f 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1,4 +1,5 @@ import CoreGraphics +import CMUXAgentLaunch import CmuxBrowser import CmuxCore import Foundation @@ -271,6 +272,7 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { case name, kind, command, cwd, checkpointId, source case environment, autoResume, approvalPolicy, approvalRecordId case launchCommand, permissionMode, launchFlavor, updatedAt + case restoreWorkingDirectorySelection case resumeEvidenceProvenance } @@ -283,6 +285,8 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { var environment: [String: String]? var launchCommand: AgentLaunchCommandSnapshot? var permissionMode: String? + /// Persisted cwd trust boundary for agent-hook restore bindings. + var restoreWorkingDirectorySelection: AgentRestoreWorkingDirectorySelection? var autoResume: Bool? /// Verified Codex hook provenance carried into the app-owned atomic gate. /// Non-Codex and legacy bindings leave this unset. @@ -304,6 +308,7 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { environment: [String: String]? = nil, launchCommand: AgentLaunchCommandSnapshot? = nil, permissionMode: String? = nil, + restoreWorkingDirectorySelection: AgentRestoreWorkingDirectorySelection? = nil, autoResume: Bool? = nil, resumeEvidenceProvenance: String? = nil, approvalPolicy: SurfaceResumeApprovalPolicy? = nil, @@ -319,7 +324,8 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { self.command = Self.sanitizedStartupCommand( command, cwd: normalizedCwd, - source: normalizedSource + source: normalizedSource, + agentKind: normalizedKind ) self.cwd = normalizedCwd self.checkpointId = Self.normalized(checkpointId) @@ -327,6 +333,7 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { self.environment = Self.normalizedEnvironment(environment) self.launchCommand = Self.normalizedLaunchCommand(launchCommand) self.permissionMode = Self.normalized(permissionMode) + self.restoreWorkingDirectorySelection = restoreWorkingDirectorySelection self.autoResume = autoResume let retainsCodexEvidence = normalizedSource?.lowercased() == "agent-hook" && normalizedKind?.lowercased() == "codex" @@ -355,6 +362,10 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { forKey: .launchCommand ), permissionMode: try container.decodeIfPresent(String.self, forKey: .permissionMode), + restoreWorkingDirectorySelection: try container.decodeIfPresent( + AgentRestoreWorkingDirectorySelection.self, + forKey: .restoreWorkingDirectorySelection + ), autoResume: try container.decodeIfPresent(Bool.self, forKey: .autoResume), resumeEvidenceProvenance: try container.decodeIfPresent(String.self, forKey: .resumeEvidenceProvenance), approvalPolicy: try container.decodeIfPresent(SurfaceResumeApprovalPolicy.self, forKey: .approvalPolicy), @@ -408,22 +419,6 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { detectedBinding.isProcessDetected && (isProcessDetected || isAgentHookBinding) } - func retargetingWorkingDirectory(_ workingDirectory: String?) -> SurfaceResumeBindingSnapshot { - guard isAgentHookBinding else { return self } - let normalizedCwd = Self.normalized(workingDirectory) - var retargeted = self - retargeted.command = TerminalStartupWorkingDirectoryPrefix.replacingRequiredChangeDirectoryPrefix( - in: command, - previousWorkingDirectory: cwd, - workingDirectory: normalizedCwd - ) - retargeted.cwd = normalizedCwd - if var launchCommand = retargeted.launchCommand { - launchCommand.workingDirectory = normalizedCwd - retargeted.launchCommand = launchCommand - } - return retargeted - } var startupInput: String? { inlineStartupInput } @@ -436,7 +431,7 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { restoreStartupInput(repairPortableAgentExecutable: true) } - private static func normalized(_ rawValue: String?) -> String? { + static func normalized(_ rawValue: String?) -> String? { guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), !rawValue.isEmpty else { return nil diff --git a/Sources/SessionRestorableAgentSnapshot+Commands.swift b/Sources/SessionRestorableAgentSnapshot+Commands.swift index 76c1a56be118..dd8241d87cf8 100644 --- a/Sources/SessionRestorableAgentSnapshot+Commands.swift +++ b/Sources/SessionRestorableAgentSnapshot+Commands.swift @@ -1,3 +1,4 @@ +import CMUXAgentLaunch import Foundation extension SessionRestorableAgentSnapshot { @@ -8,6 +9,7 @@ extension SessionRestorableAgentSnapshot { case launchCommand case registration case permissionMode + case restoreWorkingDirectorySelection } init(from decoder: Decoder) throws { @@ -37,7 +39,11 @@ extension SessionRestorableAgentSnapshot { ), registration: registration, // Optional so snapshots persisted before the field decode unchanged. - permissionMode: try container.decodeIfPresent(String.self, forKey: .permissionMode) + permissionMode: try container.decodeIfPresent(String.self, forKey: .permissionMode), + restoreWorkingDirectorySelection: try container.decodeIfPresent( + AgentRestoreWorkingDirectorySelection.self, + forKey: .restoreWorkingDirectorySelection + ) ) } @@ -45,23 +51,172 @@ extension SessionRestorableAgentSnapshot { resumeCommand(includeWorkingDirectoryPrefix: true) } + /// Returns the cwd-option policy identity only when the snapshot carries + /// cmux's exact built-in registration (or no Vault registration at all). + /// A user registration may reuse a registry-owned id such as `kimi`, so its + /// raw kind must not enable built-in-only option removal. + var workingDirectoryOptionPolicyBuiltInKind: String? { + if case .custom = kind { + return registration?.registeredResumeKind?.rawValue + } + return registration == nil || registration?.registeredResumeKind != nil + ? kind.rawValue + : nil + } + + /// Returns a copy whose persisted cwd state cannot outlive the supplied trust decision. + func applyingRestoreWorkingDirectorySelection( + _ proposedSelection: AgentRestoreWorkingDirectorySelection + ) -> SessionRestorableAgentSnapshot { + let selection = effectiveRestoreWorkingDirectorySelection(proposedSelection) + var constrained = self + switch selection { + case .recordedFallback: + constrained.restoreWorkingDirectorySelection = selection + case .exact: + let workingDirectory = selection.resolved( + snapshotWorkingDirectory: nil, + launchWorkingDirectory: nil + ) + constrained.workingDirectory = workingDirectory + constrained.restoreWorkingDirectorySelection = .exact(workingDirectory) + constrained.launchCommand = constrainedLaunchCommand( + launchCommand, + selection: .exact(workingDirectory) + ) + case .unavailable: + constrained.workingDirectory = nil + constrained.restoreWorkingDirectorySelection = .unavailable + constrained.launchCommand = constrainedLaunchCommand( + launchCommand, + selection: .unavailable + ) + } + return constrained + } + + /// Returns a copy refreshed from a provenance-validated remote snapshot selection. + func refreshingAuthoritativeRestoreWorkingDirectorySelection( + _ proposedSelection: AgentRestoreWorkingDirectorySelection + ) -> SessionRestorableAgentSnapshot { + let selection = restoreWorkingDirectorySelection?.refreshedByAuthoritativeRemoteSelection( + proposedSelection + ) ?? proposedSelection + var refreshable = self + refreshable.restoreWorkingDirectorySelection = nil + return refreshable.applyingRestoreWorkingDirectorySelection(selection) + } + + /// Applies a binding's explicit cwd policy over stale snapshot state. + func applyingAuthoritativeBindingSelection( + _ selection: AgentRestoreWorkingDirectorySelection + ) -> SessionRestorableAgentSnapshot { + var unscoped = self + unscoped.restoreWorkingDirectorySelection = nil + return unscoped.applyingRestoreWorkingDirectorySelection(selection) + } + + /// Resolves an entrypoint request against the stricter policy retained on the snapshot. + func effectiveRestoreWorkingDirectorySelection( + _ proposedSelection: AgentRestoreWorkingDirectorySelection + ) -> AgentRestoreWorkingDirectorySelection { + let selected = restoreWorkingDirectorySelection?.restricted(by: proposedSelection) + ?? proposedSelection + guard registration?.cwd == .ignore else { return selected } + return selected.permitsResume ? .exact(nil) : .unavailable + } + + /// Removes captured cwd state from a launch override when the retained policy requires it. + func constrainedLaunchCommand( + _ candidate: AgentLaunchCommandSnapshot?, + selection: AgentRestoreWorkingDirectorySelection + ) -> AgentLaunchCommandSnapshot? { + guard selection.discardsRecordedCwdOptions, var candidate else { + return candidate + } + candidate.arguments = AgentLaunchSanitizer.removingSavedWorkingDirectoryOptions( + from: candidate.arguments, + workingDirectory: nil, + agentKind: workingDirectoryOptionPolicyBuiltInKind, + removeAllWorkingDirectoryOptions: true + ) + candidate.workingDirectory = nil + return candidate + } + + func preparedResumeArguments( + launchCommand: AgentLaunchCommandSnapshot?, + workingDirectory: String?, + observedPermissionMode: String? + ) -> [String]? { + preparedResumeArguments( + launchCommand: launchCommand, + workingDirectorySelection: .recordedFallback(preferred: workingDirectory), + observedPermissionMode: observedPermissionMode + ) + } + + func preparedResumeArguments( + launchCommand: AgentLaunchCommandSnapshot?, + workingDirectorySelection: AgentRestoreWorkingDirectorySelection, + observedPermissionMode: String? + ) -> [String]? { + let selection = effectiveRestoreWorkingDirectorySelection(workingDirectorySelection) + guard selection.permitsResume else { return nil } + let effectiveLaunchCommand = constrainedLaunchCommand( + launchCommand, + selection: selection + ) + let workingDirectory = selection.resolved( + snapshotWorkingDirectory: self.workingDirectory, + launchWorkingDirectory: effectiveLaunchCommand?.workingDirectory + ) + return AgentResumeCommandBuilder.resumeArguments( + kind: kind, + sessionId: sessionId, + launchCommand: effectiveLaunchCommand, + workingDirectory: workingDirectory, + customRegistration: registration, + observedPermissionMode: observedPermissionMode + ) + } + func resumeCommand( includeWorkingDirectoryPrefix: Bool, restoringWorkingDirectory: String? = nil ) -> String? { - let effectiveWorkingDirectory = restoringWorkingDirectory ?? workingDirectory + resumeCommand( + includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix, + workingDirectorySelection: .recordedFallback(preferred: restoringWorkingDirectory) + ) + } + + func resumeCommand( + includeWorkingDirectoryPrefix: Bool, + workingDirectorySelection: AgentRestoreWorkingDirectorySelection + ) -> String? { + let selection = effectiveRestoreWorkingDirectorySelection(workingDirectorySelection) + guard selection.permitsResume else { return nil } + let effectiveLaunchCommand = constrainedLaunchCommand( + launchCommand, + selection: selection + ) + let effectiveWorkingDirectory = selection.resolved( + snapshotWorkingDirectory: workingDirectory, + launchWorkingDirectory: effectiveLaunchCommand?.workingDirectory + ) if kind.restoreMode == .relaunchCommand { return AgentRelaunchCommandBuilder().shellCommand( kind: kind, - launchCommand: launchCommand, - workingDirectory: effectiveWorkingDirectory, + launchCommand: effectiveLaunchCommand, + resolvedWorkingDirectory: effectiveWorkingDirectory, includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix ) } return AgentResumeCommandBuilder.resumeShellCommand( kind: kind, sessionId: sessionId, - launchCommand: launchCommand, + launchCommand: effectiveLaunchCommand, workingDirectory: effectiveWorkingDirectory, registrationOverride: registration, includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix, @@ -71,11 +226,23 @@ extension SessionRestorableAgentSnapshot { var forkCommand: String? { guard kind.restoreMode == .resumeSession else { return nil } + let selection = effectiveRestoreWorkingDirectorySelection( + .recordedFallback(preferred: nil) + ) + guard selection.permitsResume else { return nil } + let effectiveLaunchCommand = constrainedLaunchCommand( + launchCommand, + selection: selection + ) + let effectiveWorkingDirectory = selection.resolved( + snapshotWorkingDirectory: workingDirectory, + launchWorkingDirectory: effectiveLaunchCommand?.workingDirectory + ) return AgentResumeCommandBuilder.forkShellCommand( kind: kind, sessionId: sessionId, - launchCommand: launchCommand, - workingDirectory: workingDirectory, + launchCommand: effectiveLaunchCommand, + workingDirectory: effectiveWorkingDirectory, registrationOverride: registration, observedPermissionMode: permissionMode ) @@ -89,3 +256,131 @@ extension SessionRestorableAgentSnapshot { return kind.displayName } } + +extension SurfaceResumeBindingSnapshot { + /// Rebuilds an explicit restore command from structured launch data, or fails closed. + func constrainedRestoreCommand( + selection: AgentRestoreWorkingDirectorySelection, + includeWorkingDirectoryPrefix: Bool, + registration: CmuxVaultAgentRegistration?, + repairPortableAgentExecutable: Bool + ) -> String? { + guard case .exact = selection, + let rawKind = kind, + let agentKind = RestorableAgentKind( + persistedRawValue: rawKind, + registration: registration + ), + let sessionId = checkpointId, + registration != nil || agentKind.customAgentID == nil else { + return nil + } + var structuredLaunchCommand = launchCommand + if let bindingEnvironment = environment, !bindingEnvironment.isEmpty { + // Keep binding-only environment captures on the same builder path even when the + // hook did not provide structured argv; an empty argv lets the agent kind supply + // its normal executable while preserving the replay-safe environment. + var launch = structuredLaunchCommand ?? AgentLaunchCommandSnapshot(arguments: []) + var mergedEnvironment = launch.environment ?? [:] + mergedEnvironment.merge(bindingEnvironment) { _, bindingValue in bindingValue } + launch.environment = mergedEnvironment.isEmpty ? nil : mergedEnvironment + structuredLaunchCommand = launch + } + let agent = SessionRestorableAgentSnapshot( + kind: agentKind, + sessionId: sessionId, + workingDirectory: cwd, + launchCommand: structuredLaunchCommand, + registration: registration, + permissionMode: permissionMode + ).applyingAuthoritativeBindingSelection(selection) + guard let command = agent.resumeCommand( + includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix, + workingDirectorySelection: selection + ) else { + return nil + } + let repairedCommand = repairPortableAgentExecutable + ? SurfaceResumeCommandCanonicalizer.replacingPortableAgentExecutable( + in: command, + kind: agentKind.rawValue + ) + : command + return AgentRestoreLaunch(kind: agentKind.rawValue, sessionID: sessionId)? + .applying(toStoredCommand: repairedCommand) ?? repairedCommand + } + + /// Carries an agent snapshot's cwd trust boundary onto its persisted hook binding. + func applyingRestoreWorkingDirectorySelection( + _ selection: AgentRestoreWorkingDirectorySelection, + from agent: SessionRestorableAgentSnapshot + ) -> SurfaceResumeBindingSnapshot { + let effectiveSelection: AgentRestoreWorkingDirectorySelection + switch restoreWorkingDirectorySelection { + case .exact(let workingDirectory): + effectiveSelection = .exact(workingDirectory) + case .unavailable: + effectiveSelection = .unavailable + case .recordedFallback, nil: + effectiveSelection = selection + } + var constrained = self + constrained.restoreWorkingDirectorySelection = effectiveSelection + + guard effectiveSelection.permitsResume else { + return constrained.invalidatingAgentRestoreRecipe() + } + + var bindingAgent = agent + bindingAgent.launchCommand = launchCommand ?? agent.launchCommand + bindingAgent.permissionMode = permissionMode ?? agent.permissionMode + let constrainedAgent = bindingAgent.applyingAuthoritativeBindingSelection(effectiveSelection) + guard let command = constrainedAgent.resumeCommand( + includeWorkingDirectoryPrefix: true, + workingDirectorySelection: effectiveSelection + ) else { + return constrained.invalidatingAgentRestoreRecipe() + } + + constrained.command = command + constrained.cwd = effectiveSelection.resolved( + snapshotWorkingDirectory: constrainedAgent.workingDirectory, + launchWorkingDirectory: constrainedAgent.launchCommand?.workingDirectory + ) + constrained.launchCommand = constrainedAgent.launchCommand + return constrained + } + + /// Refreshes a persisted binding from a provenance-validated remote report. + func applyingAuthoritativeRemoteRestoreWorkingDirectorySelection( + _ selection: AgentRestoreWorkingDirectorySelection, + from agent: SessionRestorableAgentSnapshot + ) -> SurfaceResumeBindingSnapshot { + var refreshed = self + refreshed.restoreWorkingDirectorySelection = nil + return refreshed.applyingRestoreWorkingDirectorySelection(selection, from: agent) + } + + /// Removes every persisted field that could reconstruct an unavailable agent restore. + func invalidatingAgentRestoreRecipe() -> SurfaceResumeBindingSnapshot { + var invalidated = self + invalidated.restoreWorkingDirectorySelection = .unavailable + invalidated.command = "" + invalidated.cwd = nil + invalidated.launchCommand = nil + return invalidated + } + + /// Preserves a same-session binding's already-constrained restart recipe. + func inheritingRestoreWorkingDirectorySelection( + from previous: SurfaceResumeBindingSnapshot + ) -> SurfaceResumeBindingSnapshot { + guard let selection = previous.restoreWorkingDirectorySelection else { return self } + var constrained = self + constrained.restoreWorkingDirectorySelection = selection + constrained.command = previous.command + constrained.cwd = previous.cwd + constrained.launchCommand = previous.launchCommand + return constrained + } +} diff --git a/Sources/SessionRestorableAgentSnapshot+ForkVerb.swift b/Sources/SessionRestorableAgentSnapshot+ForkVerb.swift index 971e4c5f1f93..0126fd356bc2 100644 --- a/Sources/SessionRestorableAgentSnapshot+ForkVerb.swift +++ b/Sources/SessionRestorableAgentSnapshot+ForkVerb.swift @@ -5,11 +5,23 @@ extension SessionRestorableAgentSnapshot { /// Renders the compatibility fork command for a destination working directory. func forkCommand(restoringWorkingDirectory: String?) -> String? { guard kind.restoreMode == .resumeSession else { return nil } + let selection = effectiveRestoreWorkingDirectorySelection( + .recordedFallback(preferred: restoringWorkingDirectory) + ) + guard selection.permitsResume else { return nil } + let effectiveLaunchCommand = constrainedLaunchCommand( + launchCommand, + selection: selection + ) + let effectiveWorkingDirectory = selection.resolved( + snapshotWorkingDirectory: workingDirectory, + launchWorkingDirectory: effectiveLaunchCommand?.workingDirectory + ) return AgentResumeCommandBuilder.forkShellCommand( kind: kind, sessionId: sessionId, - launchCommand: launchCommand, - workingDirectory: restoringWorkingDirectory ?? workingDirectory, + launchCommand: effectiveLaunchCommand, + workingDirectory: effectiveWorkingDirectory, registrationOverride: registration, observedPermissionMode: permissionMode ) @@ -18,9 +30,17 @@ extension SessionRestorableAgentSnapshot { /// Returns a fork snapshot retargeted to the directory selected by the /// destination surface while preserving the captured launch metadata. func retargetingForkWorkingDirectory(_ workingDirectory: String?) -> Self { - let effectiveWorkingDirectory = registration?.cwd == .ignore ? nil : workingDirectory + let preservesUnavailablePolicy = restoreWorkingDirectorySelection == .unavailable + let effectiveWorkingDirectory: String? = if preservesUnavailablePolicy || registration?.cwd == .ignore { + nil + } else { + workingDirectory + } var retargeted = self retargeted.workingDirectory = effectiveWorkingDirectory + retargeted.restoreWorkingDirectorySelection = preservesUnavailablePolicy + ? .unavailable + : .exact(effectiveWorkingDirectory) if var launchCommand = retargeted.launchCommand { launchCommand.workingDirectory = effectiveWorkingDirectory retargeted.launchCommand = launchCommand @@ -39,11 +59,22 @@ extension SessionRestorableAgentSnapshot { observedPermissionMode: String? = nil ) -> [String]? { guard kind.restoreMode == .resumeSession else { return nil } + let selection = effectiveRestoreWorkingDirectorySelection( + .recordedFallback(preferred: workingDirectory) + ) + guard selection.permitsResume else { return nil } + let effectiveLaunchCommand = constrainedLaunchCommand( + launchCommand ?? self.launchCommand, + selection: selection + ) return AgentResumeCommandBuilder.forkArguments( kind: kind, sessionId: sessionId, - launchCommand: launchCommand ?? self.launchCommand, - workingDirectory: workingDirectory ?? self.workingDirectory, + launchCommand: effectiveLaunchCommand, + workingDirectory: selection.resolved( + snapshotWorkingDirectory: self.workingDirectory, + launchWorkingDirectory: effectiveLaunchCommand?.workingDirectory + ), customRegistration: registration, observedPermissionMode: observedPermissionMode ?? permissionMode ) diff --git a/Sources/SurfaceResumeBindingSnapshot+ManagedSessionIdentity.swift b/Sources/SurfaceResumeBindingSnapshot+ManagedSessionIdentity.swift index 081e40bbc7c1..ac86487752b2 100644 --- a/Sources/SurfaceResumeBindingSnapshot+ManagedSessionIdentity.swift +++ b/Sources/SurfaceResumeBindingSnapshot+ManagedSessionIdentity.swift @@ -75,14 +75,16 @@ extension SurfaceResumeBindingSnapshot { /// Whether an incoming hook refresh belongs to a claimed restore session. /// - /// A same-session refresh consumes the claim; a different checkpoint or - /// kind remains blocked until the claim expires or is explicitly cleared. + /// A same-session refresh from the same execution location consumes the + /// claim; a different checkpoint, kind, or location remains blocked until + /// the claim expires or is explicitly cleared. func acceptsRestoreBindingClaim( from incoming: SurfaceResumeBindingSnapshot ) -> Bool { isAgentHookBinding && incoming.isAgentHookBinding && isSameManagedSession(as: incoming) + && launchFlavor == incoming.launchFlavor } /// Whether storing this agent-hook write would demote an already-trusted @@ -114,8 +116,11 @@ extension SurfaceResumeBindingSnapshot { /// checkpoint may reuse only kind-level registration metadata from the /// previous snapshot; cwd, launch capture, permission mode, and identity /// must come from the new binding so a fork cannot retain its parent. + /// - Parameter previousBinding: The prior binding, when available, used to + /// ensure inherited session state stays within the same execution location. func managedRestorableAgentSnapshot( - replacing previous: SessionRestorableAgentSnapshot? + replacing previous: SessionRestorableAgentSnapshot?, + previousBinding: SurfaceResumeBindingSnapshot? ) -> SessionRestorableAgentSnapshot? { guard let identity = managedSessionIdentity else { return nil } let previousForKind = previous.flatMap { @@ -134,18 +139,42 @@ extension SurfaceResumeBindingSnapshot { rhs: identity.checkpointId ) } == true - return SessionRestorableAgentSnapshot( + let canInheritPreviousSessionState = continuesPreviousSession && + previousBinding?.launchFlavor.representsSameExecutionLocation( + as: launchFlavor + ) == true + let inheritedLaunchCommand = canInheritPreviousSessionState + ? previousForKind?.launchCommand + : nil + let effectiveLaunchCommand = launchCommand ?? inheritedLaunchCommand + let effectiveSelection = restoreWorkingDirectorySelection + ?? (canInheritPreviousSessionState + ? previousForKind?.restoreWorkingDirectorySelection + : nil) + let projectedWorkingDirectory: String? = if let effectiveSelection { + effectiveSelection.resolved( + snapshotWorkingDirectory: cwd, + launchWorkingDirectory: effectiveLaunchCommand?.workingDirectory + ) + } else { + cwd + ?? effectiveLaunchCommand?.workingDirectory + ?? (canInheritPreviousSessionState ? previousForKind?.workingDirectory : nil) + } + var snapshot = SessionRestorableAgentSnapshot( kind: kind, sessionId: identity.checkpointId, - workingDirectory: cwd - ?? launchCommand?.workingDirectory - ?? (continuesPreviousSession ? previousForKind?.workingDirectory : nil), - launchCommand: launchCommand - ?? (continuesPreviousSession ? previousForKind?.launchCommand : nil), + workingDirectory: projectedWorkingDirectory, + launchCommand: effectiveLaunchCommand, registration: previousForKind?.registration, permissionMode: permissionMode - ?? (continuesPreviousSession ? previousForKind?.permissionMode : nil) + ?? (canInheritPreviousSessionState ? previousForKind?.permissionMode : nil), + restoreWorkingDirectorySelection: effectiveSelection ) + if let effectiveSelection { + snapshot = snapshot.applyingRestoreWorkingDirectorySelection(effectiveSelection) + } + return snapshot } private var managedSessionIdentity: (kind: String, checkpointId: String)? { diff --git a/Sources/SurfaceResumeBindingSnapshot+Remote.swift b/Sources/SurfaceResumeBindingSnapshot+Remote.swift index 8b2de9058b69..263093248e30 100644 --- a/Sources/SurfaceResumeBindingSnapshot+Remote.swift +++ b/Sources/SurfaceResumeBindingSnapshot+Remote.swift @@ -1,14 +1,61 @@ +import CMUXAgentLaunch import Foundation extension SurfaceResumeBindingSnapshot { - /// Assigns trusted persistent-SSH ownership only to a legacy decoded binding. + var hasExactRestoreWorkingDirectorySelection: Bool { + guard let selection = restoreWorkingDirectorySelection else { return false } + if case .exact = selection { return true } + return false + } + + /// Allows a persistent-SSH transport reattach when exact policy intentionally omits input. + var permitsTransportOnlyPersistentSSHRestore: Bool { + isAgentHookBinding && + launchFlavor.remoteContext != nil && + restoreWorkingDirectorySelection?.discardsRecordedCwdOptions == true && + hasCompleteManagedSessionIdentity + } + + /// Assigns persistent-SSH ownership and fails closed for legacy agent-hook cwd policy. func migratingLegacyPersistentSSH(_ context: SurfaceResumeRemoteContext) -> SurfaceResumeBindingSnapshot { - guard wasDecodedWithoutLaunchFlavor else { return self } - return registeredForPersistentSSH(context) + let migrated = wasDecodedWithoutLaunchFlavor + ? replacingLaunchFlavor(.persistentSSH(context)) + : self + guard migrated.isAgentHookBinding, + migrated.restoreWorkingDirectorySelection == nil, + migrated.launchFlavor.remoteContext != nil else { + return migrated + } + return migrated.invalidatingAgentRestoreRecipe() } - func registeredForPersistentSSH(_ context: SurfaceResumeRemoteContext) -> SurfaceResumeBindingSnapshot { - replacingLaunchFlavor(.persistentSSH(context)) + /// Persists authenticated relay ownership and the relay-reported cwd trust boundary. + func registeredForPersistentSSH( + _ context: SurfaceResumeRemoteContext, + restorableAgent: SessionRestorableAgentSnapshot? = nil + ) -> SurfaceResumeBindingSnapshot { + var registered = replacingLaunchFlavor(.persistentSSH(context)) + if registered.isAgentHookBinding { + let matchingRestorableAgent = restorableAgent.flatMap { + Workspace.restorableAgentForSessionRestore($0, resumeBinding: registered) + } + let kind = matchingRestorableAgent?.kind.rawValue ?? registered.kind ?? "" + let matchingSelectionIsExact = matchingRestorableAgent?.restoreWorkingDirectorySelection + .map { if case .exact = $0 { true } else { false } } == true + if matchingRestorableAgent?.registration?.cwd == .ignore { + registered.restoreWorkingDirectorySelection = .exact(nil) + } else if matchingRestorableAgent?.restoreWorkingDirectorySelection == .unavailable { + registered.restoreWorkingDirectorySelection = .unavailable + } else if registered.cwd != nil, + (matchingRestorableAgent == nil || matchingSelectionIsExact) { + registered.restoreWorkingDirectorySelection = .exact(registered.cwd) + } else if AgentResumeWorkingDirectory().cwdNamespacing(forKind: kind) == .cwdInFile { + registered.restoreWorkingDirectorySelection = .exact(nil) + } else { + registered.restoreWorkingDirectorySelection = .unavailable + } + } + return registered } func retargetingRemoteOwner( diff --git a/Sources/SurfaceResumeBindingSnapshot+RestoreInput.swift b/Sources/SurfaceResumeBindingSnapshot+RestoreInput.swift new file mode 100644 index 000000000000..f18c4d81f40e --- /dev/null +++ b/Sources/SurfaceResumeBindingSnapshot+RestoreInput.swift @@ -0,0 +1,112 @@ +import CMUXAgentLaunch +import Foundation + +extension SurfaceResumeBindingSnapshot { + func inlineStartupInput( + repairPortableAgentExecutable: Bool, + includeWorkingDirectoryPrefix: Bool = true, + registration: CmuxVaultAgentRegistration? = nil + ) -> String? { + guard restoreWorkingDirectorySelection?.permitsResume != false else { + return nil + } + let resolvedCommand: String + if let selection = restoreWorkingDirectorySelection, + selection.discardsRecordedCwdOptions { + guard let constrainedCommand = constrainedRestoreCommand( + selection: selection, + includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix, + registration: registration, + repairPortableAgentExecutable: repairPortableAgentExecutable + ) else { + return nil + } + resolvedCommand = constrainedCommand + } else { + resolvedCommand = resolvedStartupCommand( + repairPortableAgentExecutable: repairPortableAgentExecutable + ) + } + let command = includeWorkingDirectoryPrefix + ? resolvedCommand + : TerminalStartupWorkingDirectoryPrefix.removingRequiredChangeDirectoryPrefix( + from: resolvedCommand, + workingDirectory: cwd + ) + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + // Stays raw POSIX: remote restores (remoteStartupInput) hand this to + // the remote host's shell, and local callers apply the nushell dialect + // envelope at their own typed boundary (restoreStartupInput). Wrapping + // here would leak `^/bin/sh …` into contexts that parse POSIX. + guard let environment, !environment.isEmpty else { + return trimmed + "\n" + } + let assignments = environment.keys.sorted().compactMap { key -> String? in + guard let value = environment[key] else { return nil } + return "\(key)=\(value)" + } + let argv = ["/usr/bin/env"] + assignments + ["/bin/zsh", "-lc", trimmed] + return argv.map(Self.shellSingleQuoted).joined(separator: " ") + "\n" + } + + func restoreStartupInput( + repairPortableAgentExecutable: Bool + ) -> String? { + if usesLocalRestoreVerb { + // Bare words (` cmux restore `): parses identically in + // POSIX shells and nushell, no dialect handling needed. + return localRestoreCLIInput + } + guard let inline = inlineStartupInput( + repairPortableAgentExecutable: repairPortableAgentExecutable + ) else { + return nil + } + // The compatibility fallback is a POSIX one-liner typed into the local + // login shell, so it is the nushell dialect boundary (the trailing + // newline stays outside the wrap). Remote hosts keep raw POSIX via + // remoteStartupInput(). + let command = inline.hasSuffix("\n") ? String(inline.dropLast()) : inline + return TerminalStartupTypedShellCommand().typedInput(posixCommand: command) + "\n" + } + + func remoteStartupInput( + registration: CmuxVaultAgentRegistration? = nil + ) -> String? { + inlineStartupInput( + repairPortableAgentExecutable: false, + registration: registration + ) + } + + var localRestoreCLIInput: String { + let executable = AgentRestoreLaunch.cliStartupExecutableToken + if let kind = Self.restoreCLIArgument(kind), + let checkpointId = Self.restoreCLIArgument(checkpointId) { + return " \(executable) restore \(kind) \(checkpointId)\n" + } + return " \(executable) restore --surface\n" + } + + func resolvedStartupCommand(repairPortableAgentExecutable: Bool) -> String { + guard isAgentHookBinding else { + return startupCommand + } + let suppressed = SurfaceResumeCommandCanonicalizer.insertingCodexUpdateCheckSuppression( + in: startupCommand, + kind: kind + ) + let repaired: String + if repairPortableAgentExecutable { + repaired = SurfaceResumeCommandCanonicalizer.replacingPortableAgentExecutable( + in: suppressed, + kind: kind + ) + } else { + repaired = suppressed + } + guard let restoreLaunch = AgentRestoreLaunch(kind: kind, sessionID: checkpointId) else { return repaired } + return restoreLaunch.applying(toStoredCommand: repaired) + } +} diff --git a/Sources/SurfaceResumeBindingSnapshot+WorkingDirectory.swift b/Sources/SurfaceResumeBindingSnapshot+WorkingDirectory.swift new file mode 100644 index 000000000000..8a3a70bc5003 --- /dev/null +++ b/Sources/SurfaceResumeBindingSnapshot+WorkingDirectory.swift @@ -0,0 +1,29 @@ +import CMUXAgentLaunch +import Foundation + +extension SurfaceResumeBindingSnapshot { + func retargetingWorkingDirectory(_ workingDirectory: String?) -> SurfaceResumeBindingSnapshot { + guard isAgentHookBinding else { return self } + // Generic restore/transfer cwd is not an authenticated remote observation. + if restoreWorkingDirectorySelection?.discardsRecordedCwdOptions == true { + return self + } + let normalizedCwd = Self.normalized(workingDirectory) + var retargeted = self + let normalizedKind = Self.normalized(kind) + retargeted.command = TerminalStartupWorkingDirectoryPrefix.replacingRequiredChangeDirectoryPrefix( + in: command, + previousWorkingDirectory: cwd, + workingDirectory: normalizedCwd, + agentKind: normalizedKind + ) + retargeted.cwd = normalizedCwd + if var launchCommand = retargeted.launchCommand { + launchCommand.workingDirectory = normalizedCwd + retargeted.launchCommand = launchCommand + } + // Preserve the recorded policy; only the remote registration boundary + // may promote a reported directory to an authoritative selection. + return retargeted + } +} diff --git a/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift b/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift index c1ba939f338c..513b030ea7e5 100644 --- a/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift +++ b/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift @@ -10,102 +10,21 @@ extension SurfaceResumeBindingSnapshot { static func sanitizedStartupCommand( _ command: String, cwd: String?, - source: String? + source: String?, + agentKind: String? ) -> String { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard source == "agent-hook" else { return trimmed } return TerminalStartupWorkingDirectoryPrefix.replacingRequiredChangeDirectoryPrefix( in: trimmed, - workingDirectory: cwd + workingDirectory: cwd, + agentKind: agentKind ) } - func inlineStartupInput( - repairPortableAgentExecutable: Bool, - includeWorkingDirectoryPrefix: Bool = true - ) -> String? { - let resolvedCommand = resolvedStartupCommand( - repairPortableAgentExecutable: repairPortableAgentExecutable - ) - let command = includeWorkingDirectoryPrefix - ? resolvedCommand - : TerminalStartupWorkingDirectoryPrefix.removingRequiredChangeDirectoryPrefix( - from: resolvedCommand, - workingDirectory: cwd - ) - let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - // Stays raw POSIX: remote restores (remoteStartupInput) hand this to - // the remote host's shell, and local callers apply the nushell dialect - // envelope at their own typed boundary (restoreStartupInput). Wrapping - // here would leak `^/bin/sh …` into contexts that parse POSIX. - guard let environment, !environment.isEmpty else { - return trimmed + "\n" - } - let assignments = environment.keys.sorted().compactMap { key -> String? in - guard let value = environment[key] else { return nil } - return "\(key)=\(value)" - } - let argv = ["/usr/bin/env"] + assignments + ["/bin/zsh", "-lc", trimmed] - return argv.map(Self.shellSingleQuoted).joined(separator: " ") + "\n" - } - - func restoreStartupInput( - repairPortableAgentExecutable: Bool - ) -> String? { - if usesLocalRestoreVerb { - // Bare words (` cmux restore `): parses identically in - // POSIX shells and nushell, no dialect handling needed. - return localRestoreCLIInput - } - guard let inline = inlineStartupInput( - repairPortableAgentExecutable: repairPortableAgentExecutable - ) else { - return nil - } - // The compatibility fallback is a POSIX one-liner typed into the local - // login shell, so it is the nushell dialect boundary (the trailing - // newline stays outside the wrap). Remote hosts keep raw POSIX via - // remoteStartupInput(). - let command = inline.hasSuffix("\n") ? String(inline.dropLast()) : inline - return TerminalStartupTypedShellCommand().typedInput(posixCommand: command) + "\n" - } - - func remoteStartupInput() -> String? { - inlineStartupInput(repairPortableAgentExecutable: false) - } - - private var localRestoreCLIInput: String { - let executable = AgentRestoreLaunch.cliStartupExecutableToken - if let kind = Self.restoreCLIArgument(kind), - let checkpointId = Self.restoreCLIArgument(checkpointId) { - return " \(executable) restore \(kind) \(checkpointId)\n" - } - return " \(executable) restore --surface\n" - } - - private func resolvedStartupCommand(repairPortableAgentExecutable: Bool) -> String { - guard isAgentHookBinding else { - return startupCommand - } - let suppressed = SurfaceResumeCommandCanonicalizer.insertingCodexUpdateCheckSuppression( - in: startupCommand, - kind: kind - ) - let repaired: String - if repairPortableAgentExecutable { - repaired = SurfaceResumeCommandCanonicalizer.replacingPortableAgentExecutable( - in: suppressed, - kind: kind - ) - } else { - repaired = suppressed - } - guard let restoreLaunch = AgentRestoreLaunch(kind: kind, sessionID: checkpointId) else { return repaired } - return restoreLaunch.applying(toStoredCommand: repaired) - } } + extension AgentRestoreLaunch { func applying(toStoredCommand command: String) -> String { let words = TerminalStartupWorkingDirectoryPrefix.shellWordRanges(command) diff --git a/Sources/SurfaceResumeLaunchFlavor.swift b/Sources/SurfaceResumeLaunchFlavor.swift index 8d83a64cd453..e181998eb29f 100644 --- a/Sources/SurfaceResumeLaunchFlavor.swift +++ b/Sources/SurfaceResumeLaunchFlavor.swift @@ -23,6 +23,27 @@ enum SurfaceResumeLaunchFlavor: Equatable, Hashable, Sendable { guard case .persistentSSH(let context) = self else { return nil } return context } + + /// Whether two bindings execute in the same local or persistent-SSH + /// session, ignoring a persistent SSH owner's workspace/surface retarget. + /// + /// A moved remote surface receives a new owner context while its PTY + /// session remains the same. Restore-state inheritance must follow that + /// stable PTY identity, while local/remote transitions remain isolated. + func representsSameExecutionLocation(as other: Self) -> Bool { + switch (self, other) { + case (.local, .local): + return true + case let (.persistentSSH(lhs), .persistentSSH(rhs)): + guard let lhsSessionID = lhs.normalizedPersistentPTYSessionID, + let rhsSessionID = rhs.normalizedPersistentPTYSessionID else { + return false + } + return lhsSessionID == rhsSessionID + default: + return false + } + } } extension SurfaceResumeLaunchFlavor: Codable { diff --git a/Sources/SurfaceResumeRemoteContext.swift b/Sources/SurfaceResumeRemoteContext.swift index efa57f08727e..79d52209f1d6 100644 --- a/Sources/SurfaceResumeRemoteContext.swift +++ b/Sources/SurfaceResumeRemoteContext.swift @@ -6,6 +6,11 @@ struct SurfaceResumeRemoteContext: Codable, Equatable, Hashable, Sendable { let surfaceID: UUID let persistentPTYSessionID: String + /// Stable remote execution identity across workspace/surface retargeting. + var normalizedPersistentPTYSessionID: String? { + normalizedSessionID(persistentPTYSessionID) + } + func retargeted( workspaceID: UUID, surfaceID: UUID, diff --git a/Sources/Surfaces/CloudWorkspaceLayoutTranslator.swift b/Sources/Surfaces/CloudWorkspaceLayoutTranslator.swift index 31b781aec0c5..b7f2a45bb7f3 100644 --- a/Sources/Surfaces/CloudWorkspaceLayoutTranslator.swift +++ b/Sources/Surfaces/CloudWorkspaceLayoutTranslator.swift @@ -84,7 +84,14 @@ enum CloudWorkspaceLayoutTranslator { var trees: [SurfaceProjectionLayout] = [] for screen in tables.screens { guard let document = screen.layout else { return nil } - let root = document["root"] ?? (document["kind"] != nil ? document : nil) + let root: Any? + if let nestedRoot = document["root"] { + root = nestedRoot + } else if document["kind"] != nil { + root = document + } else { + root = nil + } do { if let tree = try build(root, screen: screen, tables: tables) { trees.append(tree) } } catch { diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift index ad555315a4a0..bd95fe832924 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift @@ -1375,7 +1375,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { // the local banners for those rows go with them. guard let store = AppDelegate.shared?.notificationStore else { return } let removedIDs = Set(ids) - for notification in store.notifications where notification.correlationKey.map { CloudNotificationCorrelation.matches($0, machineID: machineID, notificationIDs: removedIDs) } == true { + for notification in store.notifications where notification.correlationKey.map({ CloudNotificationCorrelation.matches($0, machineID: machineID, notificationIDs: removedIDs) }) == true { store.remove(id: notification.id) } } diff --git a/Sources/Surfaces/TabManager+CloudAgentTitle.swift b/Sources/Surfaces/TabManager+CloudAgentTitle.swift index a8d76a49ee7d..da6bc2eafacc 100644 --- a/Sources/Surfaces/TabManager+CloudAgentTitle.swift +++ b/Sources/Surfaces/TabManager+CloudAgentTitle.swift @@ -9,8 +9,9 @@ extension TabManager { tabId: UUID, panelId: UUID, title: String, - catalog: SurfaceCatalog = .shared + catalog: SurfaceCatalog? = nil ) -> Bool { + let catalog = catalog ?? .shared guard let workspace = workspacesById[tabId] else { return false } if workspace.cloudProjectedResource(forPanel: panelId, catalog: catalog)?.kind == .terminal { return workspace.setPanelCustomTitle(panelId: panelId, title: title, source: .auto, catalog: catalog) diff --git a/Sources/Surfaces/Workspace+CloudTerminalCreation.swift b/Sources/Surfaces/Workspace+CloudTerminalCreation.swift index 12dcf73f8db9..dcbf846714da 100644 --- a/Sources/Surfaces/Workspace+CloudTerminalCreation.swift +++ b/Sources/Surfaces/Workspace+CloudTerminalCreation.swift @@ -17,7 +17,8 @@ import Foundation @MainActor extension Workspace { /// The cloud resource behind a panel, when the panel projects one. - func cloudProjectedResource(forPanel panelID: UUID, catalog: SurfaceCatalog = .shared) -> SurfaceResource? { + func cloudProjectedResource(forPanel panelID: UUID, catalog: SurfaceCatalog? = nil) -> SurfaceResource? { + let catalog = catalog ?? .shared guard let projection = catalog.projection(forPanel: panelID), projection.workspaceID == id, !projection.resource.machine.isLocal else { return nil } diff --git a/Sources/Surfaces/Workspace+PanelCustomTitle.swift b/Sources/Surfaces/Workspace+PanelCustomTitle.swift index 86f7d198f0bf..621818b66feb 100644 --- a/Sources/Surfaces/Workspace+PanelCustomTitle.swift +++ b/Sources/Surfaces/Workspace+PanelCustomTitle.swift @@ -9,8 +9,9 @@ extension Workspace { source: CustomTitleSource = .user, propagateToRemoteTmux: Bool = true, propagateToCloud: Bool = true, - catalog: SurfaceCatalog = .shared + catalog: SurfaceCatalog? = nil ) -> Bool { + let catalog = catalog ?? .shared guard panels[panelId] != nil else { return false } let previousWorkspaceTitle = self.title defer { diff --git a/Sources/TabManager+WorkspaceCustomTitle.swift b/Sources/TabManager+WorkspaceCustomTitle.swift index 943f5891dafd..48d6f8d26a11 100644 --- a/Sources/TabManager+WorkspaceCustomTitle.swift +++ b/Sources/TabManager+WorkspaceCustomTitle.swift @@ -28,8 +28,9 @@ extension TabManager { source: Workspace.CustomTitleSource = .user, propagateToRemoteTmux: Bool = true, propagateToCloud: Bool = true, - catalog: SurfaceCatalog = .shared + catalog: SurfaceCatalog? = nil ) -> Bool { + let catalog = catalog ?? .shared guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return false } let previousCustomTitle = tabs[index].customTitle let previousSource = tabs[index].effectiveCustomTitleSource diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 1bf2623bf6d3..d34d74bfa73a 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -493,6 +493,7 @@ class TabManager: ObservableObject { private var currentWindowTabBarLeadingInset: CGFloat? private var closeConfirmationInFlight = false let closeTabWarningDefaults: UserDefaults + let agentSessionAutoResumeDefaults: UserDefaults let tabDragTransferRegistry: TabDragTransferRegistry /// File-backed panels in every workspace and Dock owned by this window /// share this injected invalidation pipeline. @@ -507,7 +508,6 @@ class TabManager: ObservableObject { private var debugPendingWorkspaceSwitchTarget: UUID? private var debugPreparedWorkspaceSwitchTarget: UUID? #endif - #if DEBUG private var didSetupSplitCloseRightUITest = false private var didSetupUITestFocusShortcuts = false @@ -515,7 +515,6 @@ class TabManager: ObservableObject { private var didSetupChildExitKeyboardUITest = false private var uiTestCancellables = Set() #endif - // Process-wide cap on concurrent sidebar git snapshot probes, shared by // every window's SidebarGitMetadataService. A static (not a per-instance // default) on purpose: the cap is per process, not per window, matching @@ -532,7 +531,6 @@ class TabManager: ObservableObject { /// GitHub transport state injected process-wide by the app composition root. /// The fallback initializer is retained for isolated `TabManager` tests. let pullRequestProbeService: PullRequestProbeService - private let managedDevicePolicy: ManagedDevicePolicy init( @@ -562,8 +560,9 @@ class TabManager: ObservableObject { }, workspaceCustomizationStore: WorkspaceCustomizationStore? = nil, nativeSSHConnectionBroker: NativeSSHConnectionBroker = NativeSSHConnectionBroker(), - agentChatResumeIntentRecorder: any AgentChatResumeIntentRecording = AgentChatTranscriptResumeIntentRecorder(), closeTabWarningDefaults: UserDefaults = .standard, + agentSessionAutoResumeDefaults: UserDefaults = .standard, + agentChatResumeIntentRecorder: any AgentChatResumeIntentRecording = AgentChatTranscriptResumeIntentRecorder(), managedDevicePolicy: ManagedDevicePolicy = ManagedDevicePolicy(), fileContentChangeCoordinator: FileContentChangeCoordinator? = nil ) { @@ -585,6 +584,7 @@ class TabManager: ObservableObject { self.panelTitleUpdateCoalescer = panelTitleUpdateCoalescer ?? NotificationBurstCoalescer() self.windowTitleWriter = windowTitleWriter ?? WindowTitleWriter() self.closeTabWarningDefaults = closeTabWarningDefaults + self.agentSessionAutoResumeDefaults = agentSessionAutoResumeDefaults self.tabDragTransferRegistry = tabDragTransferRegistry self.fileContentChangeCoordinator = fileContentChangeCoordinator ?? FileContentChangeCoordinator() @@ -1136,7 +1136,7 @@ class TabManager: ObservableObject { allowTextBoxFocusDefault: allowTextBoxFocusDefault, tabDragTransferRegistry: tabDragTransferRegistry, settings: settings, - closeTabWarningDefaults: closeTabWarningDefaults, + closeTabWarningDefaults: closeTabWarningDefaults, agentSessionAutoResumeDefaults: agentSessionAutoResumeDefaults, agentChatResumeIntentRecorder: agentChatResumeIntentRecorder, fileContentChangeCoordinator: fileContentChangeCoordinator, nativeSSHConnectionBroker: nativeSSHConnectionBroker @@ -1157,7 +1157,7 @@ class TabManager: ObservableObject { configTemplate: configTemplate, tabDragTransferRegistry: tabDragTransferRegistry, settings: settings, - closeTabWarningDefaults: closeTabWarningDefaults, + closeTabWarningDefaults: closeTabWarningDefaults, agentSessionAutoResumeDefaults: agentSessionAutoResumeDefaults, initialDetachedSurface: detachedSurface, agentChatResumeIntentRecorder: agentChatResumeIntentRecorder, fileContentChangeCoordinator: fileContentChangeCoordinator, @@ -1172,7 +1172,7 @@ class TabManager: ObservableObject { baseDirectoryProvider: { nil }, remoteBrowserSettingsProvider: { .local }, tabDragTransferRegistry: tabDragTransferRegistry, - settings: settings, + settings: settings, agentSessionAutoResumeDefaults: agentSessionAutoResumeDefaults, agentChatResumeIntentRecorder: agentChatResumeIntentRecorder, fileContentChangeCoordinator: fileContentChangeCoordinator ) @@ -6293,8 +6293,8 @@ extension TabManager { hasher.combine(snapshot.sessionId) hashOptionalString(snapshot.workingDirectory, into: &hasher) hashAgentLaunchCommand(snapshot.launchCommand, into: &hasher) + hasher.combine(snapshot.restoreWorkingDirectorySelection) } - nonisolated private static func hashAgentLaunchCommand( _ launchCommand: AgentLaunchCommandSnapshot?, into hasher: inout Hasher @@ -6303,7 +6303,6 @@ extension TabManager { hasher.combine(false) return } - hasher.combine(true) hashOptionalString(launchCommand.launcher, into: &hasher) hashOptionalString(launchCommand.executablePath, into: &hasher) @@ -6361,6 +6360,7 @@ extension TabManager { hashOptionalString(snapshot.resumeEvidenceProvenance, into: &hasher) hasher.combine(snapshot.allowsAutomaticResume) hasher.combine(snapshot.launchFlavor) + hasher.combine(snapshot.restoreWorkingDirectorySelection) if snapshot.isProcessDetected { hasher.combine(false) } else { @@ -6646,7 +6646,7 @@ extension TabManager { portOrdinal: ordinal, tabDragTransferRegistry: tabDragTransferRegistry, settings: settings, - closeTabWarningDefaults: closeTabWarningDefaults, + closeTabWarningDefaults: closeTabWarningDefaults, agentSessionAutoResumeDefaults: agentSessionAutoResumeDefaults, agentChatResumeIntentRecorder: agentChatResumeIntentRecorder, fileContentChangeCoordinator: fileContentChangeCoordinator, nativeSSHConnectionBroker: nativeSSHConnectionBroker @@ -6682,7 +6682,7 @@ extension TabManager { portOrdinal: ordinal, tabDragTransferRegistry: tabDragTransferRegistry, settings: settings, - closeTabWarningDefaults: closeTabWarningDefaults, + closeTabWarningDefaults: closeTabWarningDefaults, agentSessionAutoResumeDefaults: agentSessionAutoResumeDefaults, agentChatResumeIntentRecorder: agentChatResumeIntentRecorder, fileContentChangeCoordinator: fileContentChangeCoordinator, nativeSSHConnectionBroker: nativeSSHConnectionBroker diff --git a/Sources/TerminalController+SurfaceResumeBindings.swift b/Sources/TerminalController+SurfaceResumeBindings.swift index 525b175afc4a..92e2363662c6 100644 --- a/Sources/TerminalController+SurfaceResumeBindings.swift +++ b/Sources/TerminalController+SurfaceResumeBindings.swift @@ -144,6 +144,18 @@ extension TerminalController { Workspace.makeSessionRestorePolicyService() .bindingForCompatibilityShellRestore($0) } + // A persistent-SSH agent-hook binding without a stored cwd trust + // decision is legacy/unscoped state. It may still contain a local + // launch recipe, so never let the control-surface fallback turn that + // missing policy into an executable restore record. The authenticated + // hook refresh path can replace this binding with an explicit selection. + let isUnscopedRemoteAgentHook = binding?.isAgentHookBinding == true && + binding?.launchFlavor.remoteContext != nil && + binding?.restoreWorkingDirectorySelection == nil + guard !isUnscopedRemoteAgentHook else { return nil } + guard binding?.restoreWorkingDirectorySelection?.permitsResume != false else { + return nil + } // A hook can replace the live binding after this surface was restored, // while the restore-time agent snapshot still names the previous // conversation. Reuse the session-restore identity gate so the record @@ -180,8 +192,13 @@ extension TerminalController { compatibilityBinding: compatibilityBinding ) } + guard binding?.isAgentHookBinding != true || + target.restorableAgent?.restoreWorkingDirectorySelection == nil else { + return nil + } guard let binding else { return nil } return controlSurfaceBindingContinuationRecord( + target: target, binding: binding, compatibilityBinding: compatibilityBinding, restoredAgentExists: restoredAgent != nil && binding.isAgentHookBinding diff --git a/Sources/TerminalStartupWorkingDirectoryPrefix+SavedOptions.swift b/Sources/TerminalStartupWorkingDirectoryPrefix+SavedOptions.swift new file mode 100644 index 000000000000..630573e56f8b --- /dev/null +++ b/Sources/TerminalStartupWorkingDirectoryPrefix+SavedOptions.swift @@ -0,0 +1,55 @@ +import CMUXAgentLaunch +import Foundation + +extension TerminalStartupWorkingDirectoryPrefix { + /// Finds cwd options that duplicate the saved directory using the agent's option semantics. + static func savedWorkingDirectoryOptionRanges( + in words: [ShellWordRange], + workingDirectory: String, + agentKind: String? + ) -> [Range] { + let optionPolicy = AgentWorkingDirectoryOptionPolicy(agentKind: agentKind) + let valueOptions = optionPolicy.valueOptions + let optionPrefixes = valueOptions.map { "\($0)=" } + var ranges: [Range] = [] + var index = 0 + while index < words.count { + let arg = words[index].value + if arg == "--" { break } + if valueOptions.contains(arg), + index + 1 < words.count, + workingDirectoryValue(words[index + 1].value, matches: workingDirectory) { + ranges.append(words[index].range.lowerBound.. $0.count && arg.hasPrefix($0) + }) { + let value = String(arg.dropFirst(option.count)) + if workingDirectoryValue(value, matches: workingDirectory) { + ranges.append(words[index].range) + index += 1 + continue + } + } + index += 1 + } + return ranges + } + + private static func workingDirectoryValue(_ value: String, matches workingDirectory: String) -> Bool { + guard value == workingDirectory else { + return (value as NSString).expandingTildeInPath == (workingDirectory as NSString).expandingTildeInPath + } + return true + } +} diff --git a/Sources/Workspace+AgentLifecycle.swift b/Sources/Workspace+AgentLifecycle.swift index c3de7b1b7544..2da9095977c3 100644 --- a/Sources/Workspace+AgentLifecycle.swift +++ b/Sources/Workspace+AgentLifecycle.swift @@ -301,7 +301,6 @@ extension Workspace { ? .observedAgentCommandRunning : .manualResumeAvailable } - func updateRestoredAgentResumeState( panelId: UUID, restoredAgent: SessionRestorableAgentSnapshot, @@ -343,7 +342,6 @@ extension Workspace { break } } - func updateBindingOnlyRestoredAgentResumeState( panelId: UUID, shellState: PanelShellActivityState @@ -364,7 +362,6 @@ extension Workspace { break } } - private func invalidateRestoredAgentSnapshot( panelId: UUID, restoredAgent: SessionRestorableAgentSnapshot @@ -589,7 +586,14 @@ extension Workspace { } } } - + /// The index a deferred restore resolves against: the settled refresh when + /// it completed, otherwise the most recent completed load. + nonisolated static func deferredResumeIndex( + refreshed: RestorableAgentSessionIndex?, + lastKnown: RestorableAgentSessionIndex? + ) -> RestorableAgentSessionIndex? { + refreshed ?? lastKnown + } private func resolveDeferredAgentResumeRestores( using index: RestorableAgentSessionIndex ) { @@ -709,15 +713,11 @@ extension Workspace { cancelDeferredAgentResumeRestore(panelId: panelId, restore: restore) continue } - let startupInput: String? let claim: (kind: String, sessionId: String)? if let restorableAgent = restore.restorableAgent { startupInput = if restore.restoresRemoteWorkspaceTerminalSnapshot { - restorableAgent.resumeStartupInput( - useLocalRestoreVerb: false, - restoringWorkingDirectory: restore.resumeWorkingDirectory - ) + restorableAgent.remoteResumeStartupInput() } else { restorableAgent.resumeStartupInput( restoringWorkingDirectory: restore.resumeWorkingDirectory @@ -730,6 +730,13 @@ extension Workspace { cancelDeferredAgentResumeRestore(panelId: panelId, restore: restore) continue } + guard !binding.isAgentHookBinding || + (binding.restoreWorkingDirectorySelection.map { + if case .exact = $0 { true } else { false } + } == true) else { + cancelDeferredAgentResumeRestore(panelId: panelId, restore: restore) + continue + } } let approvedBinding = policy.approvedSurfaceResumeBinding( binding, @@ -739,9 +746,14 @@ extension Workspace { promptForApproval: true, approvalStoreURL: SurfaceResumeApprovalStore.defaultURL() ) + let matchingRestorableAgent = restoredAgentSnapshotsByPanelId[panelId].flatMap { + Self.restorableAgentForSessionRestore($0, resumeBinding: binding) + } startupInput = approvedBinding.flatMap { if restore.restoresRemoteWorkspaceTerminalSnapshot { - return $0.remoteStartupInput() + return $0.remoteStartupInput( + registration: matchingRestorableAgent?.registration + ) } return policy.surfaceResumeStartupLaunch(forApprovedBinding: $0)?.initialInput } @@ -815,7 +827,9 @@ extension Workspace { } } } - +#if DEBUG + func resolveDeferredAgentResumeRestoresForTesting(using index: RestorableAgentSessionIndex) { resolveDeferredAgentResumeRestores(using: index) } +#endif func removeDeferredAgentResumeRestore(panelId: UUID) { deferredAgentResumeRestoresByPanelId.removeValue(forKey: panelId) if let claim = deferredAgentResumeClaimsByPanelId.removeValue(forKey: panelId) { @@ -825,7 +839,6 @@ extension Workspace { ) } } - func cancelDeferredAgentResumeRestore( panelId: UUID, restore: DeferredAgentResumeRestore, @@ -849,7 +862,6 @@ extension Workspace { restoredAgentLifecycle.setResumeState(.manualResumeAvailable, panelId: panelId) } } - private func deferredAgentResumeRestoreMatchesCurrentSession( panelId: UUID, restore: DeferredAgentResumeRestore @@ -874,7 +886,6 @@ extension Workspace { } else if restore.restorableAgent != nil { return false } - if restore.resumeBinding != nil { guard let currentBinding = surfaceResumeBindingsByPanelId[panelId], let currentKind = currentBinding.kind, @@ -901,7 +912,6 @@ extension Workspace { } return true } - private func retireAgentHookResumeBinding( panelId: UUID, matching binding: SurfaceResumeBindingSnapshot @@ -913,7 +923,6 @@ extension Workspace { } retireAgentHookResumeBinding(panelId: panelId) } - /// Replaces a deferred automatic resume with a typed explanation, so the /// pane says why nothing was resumed and how to resume it by hand. private func explainDeferredAgentResumeRestore( @@ -1008,17 +1017,6 @@ extension Workspace { } deferredAgentResumeRestoresByPanelId.removeAll() } - - /// Uses the most recent completed index when a refresh could not settle. - /// A missing refreshed index means the scan was inconclusive, not that the - /// previously loaded session index is stale. - nonisolated static func deferredResumeIndex( - refreshed: RestorableAgentSessionIndex?, - lastKnown: RestorableAgentSessionIndex? - ) -> RestorableAgentSessionIndex? { - refreshed ?? lastKnown - } - func agentHibernationLifecycleState( panelId: UUID, fallback: AgentHibernationLifecycleState? @@ -1028,13 +1026,11 @@ extension Workspace { fallback: fallback ) } - func agentLifecycleStateForTextBoxEscape(panelId: UUID) -> AgentHibernationLifecycleState { AgentHibernationLifecycleState.aggregateForTextBoxEscape( statusKeyedStates: agentLifecycleStatesByPanelId[panelId] ?? [:] ) } - private func recordAgentLifecycleChange(panelId: UUID) { AgentHibernationController.shared.recordAgentLifecycleChange( workspaceId: id, diff --git a/Sources/Workspace+AgentResumeOwnership.swift b/Sources/Workspace+AgentResumeOwnership.swift index 228355143957..050f644b0bac 100644 --- a/Sources/Workspace+AgentResumeOwnership.swift +++ b/Sources/Workspace+AgentResumeOwnership.swift @@ -35,7 +35,8 @@ extension Workspace { guard let binding = surfaceResumeBindingsByPanelId[panelId], binding.isAgentHookBinding, let agent = binding.managedRestorableAgentSnapshot( - replacing: restoredAgentSnapshotsByPanelId[panelId] + replacing: restoredAgentSnapshotsByPanelId[panelId], + previousBinding: nil ) else { return false } diff --git a/Sources/Workspace+RemoteSurfaceResumeBinding.swift b/Sources/Workspace+RemoteSurfaceResumeBinding.swift index cb3c94f5ad51..a691dc3cf865 100644 --- a/Sources/Workspace+RemoteSurfaceResumeBinding.swift +++ b/Sources/Workspace+RemoteSurfaceResumeBinding.swift @@ -48,7 +48,8 @@ extension Workspace { for binding: SurfaceResumeBindingSnapshot?, expectedWorkspaceID: UUID, expectedSurfaceID: UUID, - persistentPTYSessionID: String + persistentPTYSessionID: String, + restorableAgent: SessionRestorableAgentSnapshot? = nil ) -> String? { guard let binding, case .persistentSSH(let context) = binding.launchFlavor, @@ -62,10 +63,51 @@ extension Workspace { configuration.preserveAfterTerminalExit, !configuration.skipDaemonBootstrap, configuration.persistentDaemonSlot != nil, - let relayPort = configuration.relayPort, - let startupInput = binding.remoteStartupInput() else { + let relayPort = configuration.relayPort else { return nil } + // A binding with no launch recipe is an explicit transport-only + // reattach request. Do not resurrect a launch command from an older + // lifecycle snapshot just because one happens to be cached for the + // same session; doing so can replay stale/local cwd arguments. An + // explicitly supplied snapshot remains an intentional authority (the + // restore path passes one when it has validated it). + let candidateRestorableAgent = restorableAgent ?? ( + binding.launchCommand == nil ? nil : restoredAgentSnapshotsByPanelId[expectedSurfaceID] + ) + let matchingRestorableAgent = candidateRestorableAgent.flatMap { + Self.restorableAgentForSessionRestore($0, resumeBinding: binding) + } + let bindingForStartup: SurfaceResumeBindingSnapshot = if let selection = + binding.restoreWorkingDirectorySelection, + selection.discardsRecordedCwdOptions, + let matchingRestorableAgent { + binding.applyingRestoreWorkingDirectorySelection( + selection, + from: matchingRestorableAgent + ) + } else { + binding + } + let startupInput: String? + if bindingForStartup.isAgentHookBinding { + // Persistent-SSH agent-hook startup is safe only after an + // authoritative remote cwd selection. Recorded-fallback, missing, + // and unavailable policies are transport-only so a captured local + // command can never be replayed on the remote host. + if case .exact = bindingForStartup.restoreWorkingDirectorySelection { + startupInput = bindingForStartup.remoteStartupInput( + registration: matchingRestorableAgent?.registration + ) + } else { + startupInput = nil + } + } else { + guard let input = bindingForStartup.remoteStartupInput( + registration: matchingRestorableAgent?.registration + ) else { return nil } + startupInput = input + } return SSHPTYAttachStartupCommandBuilder.restoredRemoteShellCommand( relayPort: relayPort, initialCommand: startupInput, diff --git a/Sources/Workspace+SessionSnapshotProjection.swift b/Sources/Workspace+SessionSnapshotProjection.swift new file mode 100644 index 000000000000..684b9a2ab8ed --- /dev/null +++ b/Sources/Workspace+SessionSnapshotProjection.swift @@ -0,0 +1,428 @@ +import Foundation +import Bonsplit +import CMUXAgentLaunch +import CmuxCore +import CmuxWorkspaces + +extension Workspace { + func sessionSnapshot( + includeScrollback: Bool, + restorableAgentIndex: RestorableAgentSessionIndex? = nil, + surfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil, + downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable: Bool = false, + currentAgentProcessIdentity: (Int) -> AgentPIDProcessIdentity? = { + guard $0 > 0, $0 <= Int(Int32.max) else { return nil } + return AgentPIDProcessIdentity(pid: pid_t($0)) + }, + agentProcessPresence: (Int) -> PIDPresence = { + guard $0 > 0, $0 <= Int(Int32.max) else { return .absent } + return PIDPresence.current(pid: pid_t($0)) + } + ) -> SessionWorkspaceSnapshot { + let layoutCodec = SessionSplitContainerLayoutCodec(controller: bonsplitController) + let rawLayout = layoutCodec.snapshot(panelIdForTabId: { [self] in surfaceIdToPanelId[$0] }) + if let surfaceResumeBindingIndex { + reconcileSurfaceResumeBindings( + using: surfaceResumeBindingIndex, + restorableAgentIndex: restorableAgentIndex + ) + } + let orderedPanelIds = sidebarOrderedPanelIds() + var seen: Set = [] + var allPanelIds: [UUID] = [] + for panelId in orderedPanelIds where seen.insert(panelId).inserted { + allPanelIds.append(panelId) + } + for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where seen.insert(panelId).inserted { + allPanelIds.append(panelId) + } + let terminalFontSizeSnapshotProjection = + terminalFontSizeChangeArbiter?.snapshotProjection( + for: self, + panelIds: Set(allPanelIds) + ) + let panelSnapshots = allPanelIds + .prefix(SessionPersistencePolicy.maxPanelsPerWorkspace) + .compactMap { panelId in + sessionPanelSnapshot( + panelId: panelId, + includeScrollback: includeScrollback, + restorableAgentObservation: restorableAgentIndex?.entryForStablePanel( + workspaceId: id, + panelId: panelId, + processIdentityProvider: currentAgentProcessIdentity, + processPresenceProvider: agentProcessPresence, + // Snapshot projection already consumes one index result; + // avoid synchronous sysctl/kill probes on the main actor + // while autosaving or closing a workspace. + revalidateProcessEvidence: false + ), + resumeBinding: effectiveSurfaceResumeBinding( + panelId: panelId, + surfaceResumeBindingIndex: surfaceResumeBindingIndex, + downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable: + downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable + ), + terminalFontSizeSnapshotProjection: + terminalFontSizeSnapshotProjection, + currentAgentProcessIdentity: currentAgentProcessIdentity, + agentProcessPresence: agentProcessPresence + ) + } + let persistedPanelIds = Set(panelSnapshots.map(\.id)) + let layout = layoutCodec.pruned(rawLayout, keeping: persistedPanelIds) ?? .pane( + SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil) + ) + let statusSnapshots = statusEntries.values + .sorted { lhs, rhs in lhs.key < rhs.key } + .map { entry in + SessionStatusEntrySnapshot( + key: entry.key, + value: entry.value, + icon: entry.icon, + color: entry.color, + timestamp: entry.timestamp.timeIntervalSince1970 + ) + } + let logEntriesForSnapshot = isDefaultFreestyleSSHDRemoteWorkspace + ? logEntries.filter { !Self.isProxyOnlyRemoteLogEntry($0) } + : logEntries + let logSnapshots = logEntriesForSnapshot.map { entry in + SessionLogEntrySnapshot( + message: entry.message, + level: entry.level.rawValue, + source: entry.source, + timestamp: entry.timestamp.timeIntervalSince1970 + ) + } + let progressSnapshot = progress.map { progress in + SessionProgressSnapshot(value: progress.value, label: progress.label) + } + let gitBranchSnapshot = gitBranch.map { branch in + SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty) + } + let notificationStore = AppDelegate.shared?.notificationStore + let isWorkspaceManuallyUnread = notificationStore?.hasManualUnread(forTabId: id) ?? false + let hasWorkspaceUnreadIndicator = + (notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: nil) ?? false) || + (notificationStore?.hasRestoredUnreadIndicator(forTabId: id) ?? false) + let workspaceNotificationSnapshots = notificationSnapshots(surfaceId: nil) + var snapshot = SessionWorkspaceSnapshot( + workspaceId: id, + stableId: stableId, + taskCreateOperationID: taskCreateOperationID, + processTitle: processTitle, + customTitle: customTitle, + customTitleSource: effectiveCustomTitleSource == .remote ? .user : effectiveCustomTitleSource, + customTitleWasRemote: effectiveCustomTitleSource == .remote ? true : nil, + customDescription: customDescription, + customColor: customColor, + isPinned: isPinned, + isMuted: isMuted, + groupId: groupId, + isManuallyUnread: isWorkspaceManuallyUnread, + hasUnreadIndicator: hasWorkspaceUnreadIndicator, + notifications: workspaceNotificationSnapshots.isEmpty ? nil : workspaceNotificationSnapshots, + currentDirectory: currentDirectory, + focusedPanelId: focusedPanelId, + layout: layout, + layoutMode: layoutMode.rawValue, + canvasPanes: canvasSessionPaneSnapshots(), + panels: panelSnapshots, + statusEntries: statusSnapshots, + logEntries: logSnapshots, + progress: progressSnapshot, + gitBranch: gitBranchSnapshot, + remote: remoteConfiguration?.sessionSnapshot(), + cloudVM: cloudVMBinding.map { SessionCloudVMBindingSnapshot(vmID: $0.vmID, isBase: $0.isBase, remoteWorkspaceID: $0.remoteWorkspaceID) }, + surfaceProjections: surfaceProjectionRecordsForSession, + environment: workspaceEnvironment.isEmpty ? nil : workspaceEnvironment + ) + snapshot.captureTodoState(from: self) + snapshot.dock = _dockSplit?.sessionSnapshot( + includeScrollback: includeScrollback, + restorableAgentIndex: restorableAgentIndex, + surfaceResumeBindingIndex: surfaceResumeBindingIndex, + downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable: + downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable + ) + return snapshot + } + + /// Rebuilds workspace state while keeping structured terminal restores + /// behind the topology boundary selected by `startupRestoreCommitOwner`. + func restoreSessionLayout(_ layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneRestoreEntry] { + guard let rootPaneId = bonsplitController.allPaneIds.first else { + return [] + } + + var leaves: [SessionPaneRestoreEntry] = [] + restoreSessionLayoutNode(layout, inPane: rootPaneId, leaves: &leaves) + return leaves + } + + func restoreSessionLayoutNode( + _ node: SessionWorkspaceLayoutSnapshot, + inPane paneId: PaneID, + leaves: inout [SessionPaneRestoreEntry] + ) { + switch node { + case .pane(let pane): + leaves.append(SessionPaneRestoreEntry(paneId: paneId, snapshot: pane)) + case .split(let split): + var anchorPanelId = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + .first + + if anchorPanelId == nil { + anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id + } + + guard let anchorPanelId, + let newSplitPanel = newTerminalSplit( + from: anchorPanelId, + orientation: split.orientation.splitOrientation, + insertFirst: false, + focus: false + ), + let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else { + leaves.append( + SessionPaneRestoreEntry( + paneId: paneId, + snapshot: SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil) + ) + ) + return + } + + restoreSessionLayoutNode(split.first, inPane: paneId, leaves: &leaves) + restoreSessionLayoutNode(split.second, inPane: secondPaneId, leaves: &leaves) + } + } + + func restoreAgentIndex( + for panels: [SessionPanelSnapshot] + ) -> RestorableAgentSessionIndex? { + // Load at most once for this restore pass; every panel reuses the same snapshot. + guard AgentSessionAutoResumeSettings.isEnabled( + defaults: agentSessionAutoResumeDefaults + ), panels.contains(where: { panel in + panel.terminal?.agent != nil || panel.terminal?.resumeBinding?.isAgentHookBinding == true + }) else { + return nil + } + // Ownership-sensitive restore decisions use an injected authoritative + // index, or request a fresh off-main scan and defer launch otherwise. + return restorableAgentIndexProvider() + } + + func restorePane( + _ paneId: PaneID, + snapshot: SessionPaneLayoutSnapshot, + panelSnapshotsById: [UUID: SessionPanelSnapshot], + snapshotWorkspaceId: UUID?, + shouldRestoreSingleDefaultCloudTerminal: Bool, + restorableAgentIndex: RestorableAgentSessionIndex?, + oldToNewPanelIds: inout [UUID: UUID] + ) { + let existingPanelIds = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + let desiredOldPanelIds = snapshot.panelIds.filter { panelSnapshotsById[$0] != nil } + _ = bonsplitController.setFullWidthTabMode(false, inPane: paneId) + + var createdPanelIds: [UUID] = [] + for oldPanelId in desiredOldPanelIds { + guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue } + guard let createdPanelId = createPanel( + from: panelSnapshot, + inPane: paneId, + snapshotWorkspaceId: snapshotWorkspaceId, + shouldRestoreSingleDefaultCloudTerminal: shouldRestoreSingleDefaultCloudTerminal, + restorableAgentIndex: restorableAgentIndex + ) else { continue } + createdPanelIds.append(createdPanelId) + oldToNewPanelIds[oldPanelId] = createdPanelId + } + + guard !createdPanelIds.isEmpty else { return } + + for oldPanelId in existingPanelIds where !createdPanelIds.contains(oldPanelId) { + _ = closePanel(oldPanelId, force: true) + } + + for (index, panelId) in createdPanelIds.enumerated() { + _ = reorderSurface(panelId: panelId, toIndex: index) + } + + let selectedPanelId: UUID? = { + if let selectedOldId = snapshot.selectedPanelId { + return oldToNewPanelIds[selectedOldId] + } + return createdPanelIds.first + }() + + if let selectedPanelId, + let selectedTabId = surfaceIdFromPanelId(selectedPanelId) { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(selectedTabId) + } + + if snapshot.isFullWidthTabMode == true { + _ = bonsplitController.setFullWidthTabMode(true, inPane: paneId) + } + } + + func reconcileSurfaceResumeBindings( + using surfaceResumeBindingIndex: SurfaceResumeBindingIndex, + restorableAgentIndex: RestorableAgentSessionIndex? = nil + ) { + for panelId in panels.keys { + let storedBinding = surfaceResumeBindingsByPanelId[panelId] + let detectedBinding = surfaceResumeBindingIndex.binding(workspaceId: id, panelId: panelId) + if surfaceResumeBindingIndex.hasAmbiguousPanel(panelId), detectedBinding == nil { + // A missing panel-only winner is uncertainty, not proof that a + // process-backed binding exited; preserve the existing binding. + continue + } + + if let detectedBinding, detectedBinding.isPlainSSHProcessDetectedBinding { + // A fresh process observation is authoritative evidence that + // the SSH child is still alive. It also closes the restore + // observation gap so later misses can be interpreted as an + // actual exit rather than startup churn. + observedPlainSSHPanelIds.insert(panelId) + pendingPlainSSHRestorePanelIds.remove(panelId) + plainSSHDetectionMissesByPanelId[panelId] = 0 + } + + guard let storedBinding else { + if let detectedBinding, detectedBinding.isProcessDetected { + guard surfaceResumeBindingMutationAllowed( + detectedBinding, + panelId: panelId + ) else { + continue + } + surfaceResumeBindingsByPanelId[panelId] = detectedBinding + } + continue + } + guard let detectedBinding else { + if storedBinding.isPlainSSHProcessDetectedBinding { + if pendingPlainSSHRestorePanelIds.contains(panelId) { + // The restored PTY may not have exec'd `ssh` yet. Keep + // the binding for a bounded restore observation gap; + // the shell activity transition below retires it if + // SSH never starts. + let restoreMisses = (plainSSHDetectionMissesByPanelId[panelId] ?? 0) + 1 + plainSSHDetectionMissesByPanelId[panelId] = restoreMisses + if restoreMisses >= Self.plainSSHRestoreObservationMissLimit { + guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { + continue + } + surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) + pendingPlainSSHRestorePanelIds.remove(panelId) + plainSSHDetectionMissesByPanelId.removeValue(forKey: panelId) + } + continue + } + let misses = (plainSSHDetectionMissesByPanelId[panelId] ?? 0) + 1 + plainSSHDetectionMissesByPanelId[panelId] = misses + if misses >= 2 { + guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { + continue + } + surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) + observedPlainSSHPanelIds.remove(panelId) + plainSSHDetectionMissesByPanelId.removeValue(forKey: panelId) + } + continue + } + if storedBinding.isProcessDetected { + guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { + continue + } + surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) + } else if isStaleAgentHookBinding( + storedBinding, + panelId: panelId, + restorableAgentIndex: restorableAgentIndex + ) { + // Preserve explicit restore for the exited session, but + // prevent the stale binding from replaying automatically + // on the next relaunch (#8446). + retireAgentHookResumeBinding(panelId: panelId) + } + continue + } + if storedBinding.shouldYieldToDetectedSurfaceResumeBinding(detectedBinding) { + guard surfaceResumeBindingMutationAllowed( + detectedBinding, + panelId: panelId + ) else { + continue + } + invalidateRestoredAgentLifecycleIfBindingIsReplaced( + by: detectedBinding, + panelId: panelId + ) + surfaceResumeBindingsByPanelId[panelId] = detectedBinding + } else if storedBinding.isProcessDetected { + guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { + continue + } + surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) + observedPlainSSHPanelIds.remove(panelId) + pendingPlainSSHRestorePanelIds.remove(panelId) + plainSSHDetectionMissesByPanelId.removeValue(forKey: panelId) + } + } + } + + func effectiveSurfaceResumeBinding( + panelId: UUID, + surfaceResumeBindingIndex: SurfaceResumeBindingIndex?, + downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable: Bool = false + ) -> SurfaceResumeBindingSnapshot? { + let storedBinding = surfaceResumeBindingsByPanelId[panelId] + guard let surfaceResumeBindingIndex else { + guard var storedBinding, + storedBinding.isProcessDetected, + downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable else { + return storedBinding + } + // A windowless recovery freeze cannot synchronously verify process + // detection after it releases this workspace graph. Preserve the + // command for manual recovery without trusting it to auto-run. + storedBinding.autoResume = false + storedBinding.approvalPolicy = .manual + storedBinding.approvalRecordId = nil + surfaceResumeBindingsByPanelId[panelId] = storedBinding + return storedBinding + } + + let detectedBinding = surfaceResumeBindingIndex.binding(workspaceId: id, panelId: panelId) + if surfaceResumeBindingIndex.hasAmbiguousPanel(panelId), detectedBinding == nil { + // Keep an uncertain binding available for explicit manual resume, + // but never carry process-detected auto-launch through ambiguity. + return storedBinding?.disablingAutomaticResume() + } + guard let storedBinding else { return detectedBinding } + guard let detectedBinding else { + if storedBinding.isPlainSSHProcessDetectedBinding { + let misses = plainSSHDetectionMissesByPanelId[panelId] ?? 0 + if pendingPlainSSHRestorePanelIds.contains(panelId) || misses < 2 { + return storedBinding + } + return nil + } + return storedBinding.isProcessDetected ? nil : storedBinding + } + if storedBinding.shouldYieldToDetectedSurfaceResumeBinding(detectedBinding) { return detectedBinding } + if storedBinding.isProcessDetected { return nil } + return storedBinding + } + +} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index e2b085e57cc6..74d782f3368a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -58,158 +58,12 @@ private final class WorkspacePendingTerminalInputObserver: @unchecked Sendable { var observer: NSObjectProtocol? } -private struct SessionPaneRestoreEntry { +struct SessionPaneRestoreEntry { let paneId: PaneID let snapshot: SessionPaneLayoutSnapshot } extension Workspace { - func sessionSnapshot( - includeScrollback: Bool, - restorableAgentIndex: RestorableAgentSessionIndex? = nil, - surfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil, - downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable: Bool = false, - currentAgentProcessIdentity: (Int) -> AgentPIDProcessIdentity? = { - guard $0 > 0, $0 <= Int(Int32.max) else { return nil } - return AgentPIDProcessIdentity(pid: pid_t($0)) - }, - agentProcessPresence: (Int) -> PIDPresence = { - guard $0 > 0, $0 <= Int(Int32.max) else { return .absent } - return PIDPresence.current(pid: pid_t($0)) - } - ) -> SessionWorkspaceSnapshot { - let layoutCodec = SessionSplitContainerLayoutCodec(controller: bonsplitController) - let rawLayout = layoutCodec.snapshot(panelIdForTabId: { [self] in surfaceIdToPanelId[$0] }) - if let surfaceResumeBindingIndex { - reconcileSurfaceResumeBindings( - using: surfaceResumeBindingIndex, - restorableAgentIndex: restorableAgentIndex - ) - } - let orderedPanelIds = sidebarOrderedPanelIds() - var seen: Set = [] - var allPanelIds: [UUID] = [] - for panelId in orderedPanelIds where seen.insert(panelId).inserted { - allPanelIds.append(panelId) - } - for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where seen.insert(panelId).inserted { - allPanelIds.append(panelId) - } - let terminalFontSizeSnapshotProjection = - terminalFontSizeChangeArbiter?.snapshotProjection( - for: self, - panelIds: Set(allPanelIds) - ) - let panelSnapshots = allPanelIds - .prefix(SessionPersistencePolicy.maxPanelsPerWorkspace) - .compactMap { panelId in - sessionPanelSnapshot( - panelId: panelId, - includeScrollback: includeScrollback, - restorableAgentObservation: restorableAgentIndex?.entryForStablePanel( - workspaceId: id, - panelId: panelId, - processIdentityProvider: currentAgentProcessIdentity, - processPresenceProvider: agentProcessPresence, - // Snapshot projection already consumes one index result; - // avoid synchronous sysctl/kill probes on the main actor - // while autosaving or closing a workspace. - revalidateProcessEvidence: false - ), - resumeBinding: effectiveSurfaceResumeBinding( - panelId: panelId, - surfaceResumeBindingIndex: surfaceResumeBindingIndex, - downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable: - downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable - ), - terminalFontSizeSnapshotProjection: - terminalFontSizeSnapshotProjection, - currentAgentProcessIdentity: currentAgentProcessIdentity, - agentProcessPresence: agentProcessPresence - ) - } - let persistedPanelIds = Set(panelSnapshots.map(\.id)) - let layout = layoutCodec.pruned(rawLayout, keeping: persistedPanelIds) ?? .pane( - SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil) - ) - let statusSnapshots = statusEntries.values - .sorted { lhs, rhs in lhs.key < rhs.key } - .map { entry in - SessionStatusEntrySnapshot( - key: entry.key, - value: entry.value, - icon: entry.icon, - color: entry.color, - timestamp: entry.timestamp.timeIntervalSince1970 - ) - } - let logEntriesForSnapshot = isDefaultFreestyleSSHDRemoteWorkspace - ? logEntries.filter { !Self.isProxyOnlyRemoteLogEntry($0) } - : logEntries - let logSnapshots = logEntriesForSnapshot.map { entry in - SessionLogEntrySnapshot( - message: entry.message, - level: entry.level.rawValue, - source: entry.source, - timestamp: entry.timestamp.timeIntervalSince1970 - ) - } - let progressSnapshot = progress.map { progress in - SessionProgressSnapshot(value: progress.value, label: progress.label) - } - let gitBranchSnapshot = gitBranch.map { branch in - SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty) - } - let notificationStore = AppDelegate.shared?.notificationStore - let isWorkspaceManuallyUnread = notificationStore?.hasManualUnread(forTabId: id) ?? false - let hasWorkspaceUnreadIndicator = - (notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: nil) ?? false) || - (notificationStore?.hasRestoredUnreadIndicator(forTabId: id) ?? false) - let workspaceNotificationSnapshots = notificationSnapshots(surfaceId: nil) - var snapshot = SessionWorkspaceSnapshot( - workspaceId: id, - stableId: stableId, - taskCreateOperationID: taskCreateOperationID, - processTitle: processTitle, - customTitle: customTitle, - customTitleSource: effectiveCustomTitleSource == .remote ? .user : effectiveCustomTitleSource, - customTitleWasRemote: effectiveCustomTitleSource == .remote ? true : nil, - customDescription: customDescription, - customColor: customColor, - isPinned: isPinned, - isMuted: isMuted, - groupId: groupId, - isManuallyUnread: isWorkspaceManuallyUnread, - hasUnreadIndicator: hasWorkspaceUnreadIndicator, - notifications: workspaceNotificationSnapshots.isEmpty ? nil : workspaceNotificationSnapshots, - currentDirectory: currentDirectory, - focusedPanelId: focusedPanelId, - layout: layout, - layoutMode: layoutMode.rawValue, - canvasPanes: canvasSessionPaneSnapshots(), - panels: panelSnapshots, - statusEntries: statusSnapshots, - logEntries: logSnapshots, - progress: progressSnapshot, - gitBranch: gitBranchSnapshot, - remote: remoteConfiguration?.sessionSnapshot(), - cloudVM: cloudVMBinding.map { SessionCloudVMBindingSnapshot(vmID: $0.vmID, isBase: $0.isBase, remoteWorkspaceID: $0.remoteWorkspaceID) }, - surfaceProjections: surfaceProjectionRecordsForSession, - environment: workspaceEnvironment.isEmpty ? nil : workspaceEnvironment - ) - snapshot.captureTodoState(from: self) - snapshot.dock = _dockSplit?.sessionSnapshot( - includeScrollback: includeScrollback, - restorableAgentIndex: restorableAgentIndex, - surfaceResumeBindingIndex: surfaceResumeBindingIndex, - downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable: - downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable - ) - return snapshot - } - - /// Rebuilds workspace state while keeping structured terminal restores - /// behind the topology boundary selected by `startupRestoreCommitOwner`. @discardableResult func restoreSessionSnapshot( _ snapshot: SessionWorkspaceSnapshot, @@ -234,8 +88,13 @@ extension Workspace { deferBrowserPanelsDuringSessionRestore = deferBrowserPanels defer { deferBrowserPanelsDuringSessionRestore = previousDeferBrowserPanels } let previousSuppressClosedPanelHistory = suppressClosedPanelHistory + let previousIsRestoringSessionSnapshot = isRestoringSessionSnapshot suppressClosedPanelHistory = true - defer { suppressClosedPanelHistory = previousSuppressClosedPanelHistory } + isRestoringSessionSnapshot = true + defer { + suppressClosedPanelHistory = previousSuppressClosedPanelHistory + isRestoringSessionSnapshot = previousIsRestoringSessionSnapshot + } sessionRestoreIdentityExclusions.beginRestore(excluding: excludingStableIdentities) defer { sessionRestoreIdentityExclusions.endRestore() } @@ -370,9 +229,19 @@ extension Workspace { if let focusedOldPanelId = snapshot.focusedPanelId, let focusedNewPanelId = oldToNewPanelIds[focusedOldPanelId], panels[focusedNewPanelId] != nil { - focusPanel(focusedNewPanelId) + focusPanel( + focusedNewPanelId, + // A persisted hibernation is an explicit dormant state. Do + // not turn it back into a live runtime merely because restore + // selects the previously focused tab; the user can resume it + // through the normal focus/resume action. + resumeHibernatedAgent: false + ) } else if let fallbackFocusedPanelId = focusedPanelId, panels[fallbackFocusedPanelId] != nil { - focusPanel(fallbackFocusedPanelId) + focusPanel( + fallbackFocusedPanelId, + resumeHibernatedAgent: false + ) } else { scheduleFocusReconcile() } @@ -413,7 +282,7 @@ extension Workspace { return oldToNewPanelIds } - private func sessionPanelSnapshot( + func sessionPanelSnapshot( panelId: UUID, includeScrollback: Bool, restorableAgentObservation: RestorableAgentSessionIndex.Entry?, @@ -439,6 +308,18 @@ extension Workspace { localTmuxStartCommand == nil ? Self.restorableAgentForSessionRestore($0, resumeBinding: resumeBinding) : nil + }.map { indexedSnapshot in + guard let retainedSnapshot = restoredAgentSnapshotsByPanelId[panelId], + retainedSnapshot.kind.rawValue == indexedSnapshot.kind.rawValue, + ManagedAgentSessionIdentity.sessionIDsMatch( + kind: indexedSnapshot.kind.rawValue, + lhs: retainedSnapshot.sessionId, + rhs: indexedSnapshot.sessionId + ), + let selection = retainedSnapshot.restoreWorkingDirectorySelection else { + return indexedSnapshot + } + return indexedSnapshot.applyingRestoreWorkingDirectorySelection(selection) } let reconciledIndexedRestorableAgent = restoredAgentLifecycle .reconcileSnapshotWithQueuedRestoreIntent( @@ -609,14 +490,38 @@ extension Workspace { kind: bindingKind.rawValue, sessionId: bindingSessionId ) - guard let effectiveRestorableAgent, - effectiveRestorableAgent.kind.rawValue == bindingKind.rawValue, - ManagedAgentSessionIdentity.sessionIDsMatch( - kind: bindingKind.rawValue, - lhs: effectiveRestorableAgent.sessionId, - rhs: bindingSessionId - ), - let matchingObservation else { + let matchesEffectiveRestorableAgent = effectiveRestorableAgent.map { + $0.kind.rawValue == bindingKind.rawValue && + ManagedAgentSessionIdentity.sessionIDsMatch( + kind: bindingKind.rawValue, + lhs: $0.sessionId, + rhs: bindingSessionId + ) + } == true + // A panel-only index entry for another session is + // contradictory evidence even when a retained snapshot + // still matches this binding. Do not let remote shell + // activity bridge that identity gap. + guard (matchesEffectiveRestorableAgent || matchingObservation != nil), + restorableAgentObservation == nil || matchingObservation != nil else { + return false + } + // Remote hook reports are authoritative for the host-side + // process, but the local process census has no entry for + // that process. Preserve the command-running evidence so + // a remote snapshot is not downgraded to + // `wasAgentRunning = false` merely because this Mac cannot + // inspect the remote PID. + if isRemoteTerminalSurface(panelId), + panelShellActivityStates[panelId] == .commandRunning { + // A definitive exited observation wins over a stale + // shell-state value left by an unrelated command. + guard matchingObservation?.processLiveness != .exited else { + return false + } + return true + } + guard let matchingObservation else { return false } return matchingObservation.processLiveness @@ -1123,7 +1028,8 @@ extension Workspace { nonisolated static func resumeBindingForSessionRestore( _ binding: SurfaceResumeBindingSnapshot?, - restorableAgent: SessionRestorableAgentSnapshot? + restorableAgent: SessionRestorableAgentSnapshot?, + authoritativeRemoteSelection: AgentRestoreWorkingDirectorySelection? = nil ) -> SurfaceResumeBindingSnapshot? { guard let binding, binding.isAgentHookBinding, let restorableAgent else { return binding @@ -1145,6 +1051,32 @@ extension Workspace { return binding } + if let authoritativeRemoteSelection, + // ``migratingLegacyPersistentSSH`` intentionally erases an old + // binding's launch recipe. Keep that terminal fail-closed marker + // intact during restore; treating the empty recipe as a fresh + // authoritative report would recreate a resume command from the + // captured local cwd. A later authenticated hook refresh can still + // replace it through ``setSurfaceResumeBinding``. + !(binding.restoreWorkingDirectorySelection == .unavailable && + binding.command.isEmpty && + binding.launchCommand == nil) { + return binding.applyingAuthoritativeRemoteRestoreWorkingDirectorySelection( + authoritativeRemoteSelection, + from: restorableAgent + ) + } + + if let storedSelection = restorableAgent.restoreWorkingDirectorySelection { + let selection = restorableAgent.effectiveRestoreWorkingDirectorySelection( + storedSelection + ) + return binding.applyingRestoreWorkingDirectorySelection( + selection, + from: restorableAgent + ) + } + // Restore has no live hook cwd; use the snapshot's derived restorable cwd // and fall back to launch capture only for older snapshots. let snapshotRestorableWorkingDirectory = @@ -1280,280 +1212,6 @@ extension Workspace { } #endif - private func restoreSessionLayout(_ layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneRestoreEntry] { - guard let rootPaneId = bonsplitController.allPaneIds.first else { - return [] - } - - var leaves: [SessionPaneRestoreEntry] = [] - restoreSessionLayoutNode(layout, inPane: rootPaneId, leaves: &leaves) - return leaves - } - - private func restoreSessionLayoutNode( - _ node: SessionWorkspaceLayoutSnapshot, - inPane paneId: PaneID, - leaves: inout [SessionPaneRestoreEntry] - ) { - switch node { - case .pane(let pane): - leaves.append(SessionPaneRestoreEntry(paneId: paneId, snapshot: pane)) - case .split(let split): - var anchorPanelId = bonsplitController - .tabs(inPane: paneId) - .compactMap { panelIdFromSurfaceId($0.id) } - .first - - if anchorPanelId == nil { - anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id - } - - guard let anchorPanelId, - let newSplitPanel = newTerminalSplit( - from: anchorPanelId, - orientation: split.orientation.splitOrientation, - insertFirst: false, - focus: false - ), - let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else { - leaves.append( - SessionPaneRestoreEntry( - paneId: paneId, - snapshot: SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil) - ) - ) - return - } - - restoreSessionLayoutNode(split.first, inPane: paneId, leaves: &leaves) - restoreSessionLayoutNode(split.second, inPane: secondPaneId, leaves: &leaves) - } - } - - private func restoreAgentIndex( - for panels: [SessionPanelSnapshot] - ) -> RestorableAgentSessionIndex? { - // Load at most once for this restore pass; every panel reuses the same snapshot. - guard AgentSessionAutoResumeSettings.isEnabled( - defaults: agentSessionAutoResumeDefaults - ), panels.contains(where: { panel in - panel.terminal?.agent != nil || panel.terminal?.resumeBinding?.isAgentHookBinding == true - }) else { - return nil - } - // Ownership-sensitive restore decisions use an injected authoritative - // index, or request a fresh off-main scan and defer launch otherwise. - return restorableAgentIndexProvider() - } - - private func restorePane( - _ paneId: PaneID, - snapshot: SessionPaneLayoutSnapshot, - panelSnapshotsById: [UUID: SessionPanelSnapshot], - snapshotWorkspaceId: UUID?, - shouldRestoreSingleDefaultCloudTerminal: Bool, - restorableAgentIndex: RestorableAgentSessionIndex?, - oldToNewPanelIds: inout [UUID: UUID] - ) { - let existingPanelIds = bonsplitController - .tabs(inPane: paneId) - .compactMap { panelIdFromSurfaceId($0.id) } - let desiredOldPanelIds = snapshot.panelIds.filter { panelSnapshotsById[$0] != nil } - _ = bonsplitController.setFullWidthTabMode(false, inPane: paneId) - - var createdPanelIds: [UUID] = [] - for oldPanelId in desiredOldPanelIds { - guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue } - guard let createdPanelId = createPanel( - from: panelSnapshot, - inPane: paneId, - snapshotWorkspaceId: snapshotWorkspaceId, - shouldRestoreSingleDefaultCloudTerminal: shouldRestoreSingleDefaultCloudTerminal, - restorableAgentIndex: restorableAgentIndex - ) else { continue } - createdPanelIds.append(createdPanelId) - oldToNewPanelIds[oldPanelId] = createdPanelId - } - - guard !createdPanelIds.isEmpty else { return } - - for oldPanelId in existingPanelIds where !createdPanelIds.contains(oldPanelId) { - _ = closePanel(oldPanelId, force: true) - } - - for (index, panelId) in createdPanelIds.enumerated() { - _ = reorderSurface(panelId: panelId, toIndex: index) - } - - let selectedPanelId: UUID? = { - if let selectedOldId = snapshot.selectedPanelId { - return oldToNewPanelIds[selectedOldId] - } - return createdPanelIds.first - }() - - if let selectedPanelId, - let selectedTabId = surfaceIdFromPanelId(selectedPanelId) { - bonsplitController.focusPane(paneId) - bonsplitController.selectTab(selectedTabId) - } - - if snapshot.isFullWidthTabMode == true { - _ = bonsplitController.setFullWidthTabMode(true, inPane: paneId) - } - } - - func reconcileSurfaceResumeBindings( - using surfaceResumeBindingIndex: SurfaceResumeBindingIndex, - restorableAgentIndex: RestorableAgentSessionIndex? = nil - ) { - for panelId in panels.keys { - let storedBinding = surfaceResumeBindingsByPanelId[panelId] - let detectedBinding = surfaceResumeBindingIndex.binding(workspaceId: id, panelId: panelId) - if surfaceResumeBindingIndex.hasAmbiguousPanel(panelId), detectedBinding == nil { - // A missing panel-only winner is uncertainty, not proof that a - // process-backed binding exited; preserve the existing binding. - continue - } - - if let detectedBinding, detectedBinding.isPlainSSHProcessDetectedBinding { - // A fresh process observation is authoritative evidence that - // the SSH child is still alive. It also closes the restore - // observation gap so later misses can be interpreted as an - // actual exit rather than startup churn. - observedPlainSSHPanelIds.insert(panelId) - pendingPlainSSHRestorePanelIds.remove(panelId) - plainSSHDetectionMissesByPanelId[panelId] = 0 - } - - guard let storedBinding else { - if let detectedBinding, detectedBinding.isProcessDetected { - guard surfaceResumeBindingMutationAllowed( - detectedBinding, - panelId: panelId - ) else { - continue - } - surfaceResumeBindingsByPanelId[panelId] = detectedBinding - } - continue - } - guard let detectedBinding else { - if storedBinding.isPlainSSHProcessDetectedBinding { - if pendingPlainSSHRestorePanelIds.contains(panelId) { - // The restored PTY may not have exec'd `ssh` yet. Keep - // the binding for a bounded restore observation gap; - // the shell activity transition below retires it if - // SSH never starts. - let restoreMisses = (plainSSHDetectionMissesByPanelId[panelId] ?? 0) + 1 - plainSSHDetectionMissesByPanelId[panelId] = restoreMisses - if restoreMisses >= Self.plainSSHRestoreObservationMissLimit { - guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { - continue - } - surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) - pendingPlainSSHRestorePanelIds.remove(panelId) - plainSSHDetectionMissesByPanelId.removeValue(forKey: panelId) - } - continue - } - let misses = (plainSSHDetectionMissesByPanelId[panelId] ?? 0) + 1 - plainSSHDetectionMissesByPanelId[panelId] = misses - if misses >= 2 { - guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { - continue - } - surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) - observedPlainSSHPanelIds.remove(panelId) - plainSSHDetectionMissesByPanelId.removeValue(forKey: panelId) - } - continue - } - if storedBinding.isProcessDetected { - guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { - continue - } - surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) - } else if isStaleAgentHookBinding( - storedBinding, - panelId: panelId, - restorableAgentIndex: restorableAgentIndex - ) { - // Preserve explicit restore for the exited session, but - // prevent the stale binding from replaying automatically - // on the next relaunch (#8446). - retireAgentHookResumeBinding(panelId: panelId) - } - continue - } - if storedBinding.shouldYieldToDetectedSurfaceResumeBinding(detectedBinding) { - guard surfaceResumeBindingMutationAllowed( - detectedBinding, - panelId: panelId - ) else { - continue - } - invalidateRestoredAgentLifecycleIfBindingIsReplaced( - by: detectedBinding, - panelId: panelId - ) - surfaceResumeBindingsByPanelId[panelId] = detectedBinding - } else if storedBinding.isProcessDetected { - guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { - continue - } - surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) - observedPlainSSHPanelIds.remove(panelId) - pendingPlainSSHRestorePanelIds.remove(panelId) - plainSSHDetectionMissesByPanelId.removeValue(forKey: panelId) - } - } - } - - func effectiveSurfaceResumeBinding( - panelId: UUID, - surfaceResumeBindingIndex: SurfaceResumeBindingIndex?, - downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable: Bool = false - ) -> SurfaceResumeBindingSnapshot? { - let storedBinding = surfaceResumeBindingsByPanelId[panelId] - guard let surfaceResumeBindingIndex else { - guard var storedBinding, - storedBinding.isProcessDetected, - downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable else { - return storedBinding - } - // A windowless recovery freeze cannot synchronously verify process - // detection after it releases this workspace graph. Preserve the - // command for manual recovery without trusting it to auto-run. - storedBinding.autoResume = false - storedBinding.approvalPolicy = .manual - storedBinding.approvalRecordId = nil - surfaceResumeBindingsByPanelId[panelId] = storedBinding - return storedBinding - } - - let detectedBinding = surfaceResumeBindingIndex.binding(workspaceId: id, panelId: panelId) - if surfaceResumeBindingIndex.hasAmbiguousPanel(panelId), detectedBinding == nil { - // Keep an uncertain binding available for explicit manual resume, - // but never carry process-detected auto-launch through ambiguity. - return storedBinding?.disablingAutomaticResume() - } - guard let storedBinding else { return detectedBinding } - guard let detectedBinding else { - if storedBinding.isPlainSSHProcessDetectedBinding { - let misses = plainSSHDetectionMissesByPanelId[panelId] ?? 0 - if pendingPlainSSHRestorePanelIds.contains(panelId) || misses < 2 { - return storedBinding - } - return nil - } - return storedBinding.isProcessDetected ? nil : storedBinding - } - if storedBinding.shouldYieldToDetectedSurfaceResumeBinding(detectedBinding) { return detectedBinding } - if storedBinding.isProcessDetected { return nil } - return storedBinding - } - func createPanel( from snapshot: SessionPanelSnapshot, inPane paneId: PaneID, @@ -1616,18 +1274,69 @@ extension Workspace { persistentPTYSessionID: restoredRemotePTYSessionID, restoresRemoteTerminal: restoresRemoteWorkspaceTerminalSnapshot ) + let remoteRestoreWorkingDirectorySelection: AgentRestoreWorkingDirectorySelection? = { + guard restoresRemoteWorkspaceTerminalSnapshot, + let restorableAgent else { + return nil + } + guard restorableAgent.registration?.cwd != .ignore else { + return .exact(nil) + } + // A persisted exact/unavailable selection on an authenticated + // persistent-SSH binding is already a trusted remote report. + // A newer runtime cwd may supersede it for id-addressed agents, + // but directory-keyed agents must retain their launch namespace: + // a generic runtime report can drift after launch. + let persistedSelectionRemainsAuthoritative = + snapshot.directoryIsTrustedRemoteReport != true || + restorableAgent.kind.cwdNamespacing == .byDirectory + if persistedSelectionRemainsAuthoritative, + locatedResumeBinding?.isAgentHookBinding == true, + locatedResumeBinding?.launchFlavor.remoteContext != nil, + let persistedSelection = locatedResumeBinding?.restoreWorkingDirectorySelection { + switch persistedSelection { + case .exact, .unavailable: + return persistedSelection + case .recordedFallback: + break + } + } + guard restorableAgent.kind.cwdNamespacing != .byDirectory else { + // Directory-keyed agents require their launch cwd to find the + // session namespace. A trusted remote launch cwd is not persisted. + return .unavailable + } + let trustedRuntimeDirectory = snapshot.directoryIsTrustedRemoteReport == true + ? snapshot.terminal?.workingDirectory + : nil + return .exact(AgentResumeWorkingDirectory().resolve( + kind: restorableAgent.kind.rawValue, + runtimeCwd: trustedRuntimeDirectory, + launchWorkingDirectory: nil + )) + }() + let retainedRestorableAgent: SessionRestorableAgentSnapshot? = + if let remoteRestoreWorkingDirectorySelection { + restorableAgent?.refreshingAuthoritativeRestoreWorkingDirectorySelection( + remoteRestoreWorkingDirectorySelection + ) + } else { + restorableAgent + } let resumeBinding = Self.resumeBindingForSessionRestore( locatedResumeBinding, - restorableAgent: restorableAgent + restorableAgent: retainedRestorableAgent, + authoritativeRemoteSelection: remoteRestoreWorkingDirectorySelection ) // A persisted agent snapshot can coexist with a non-agent surface // binding (for example, a process-detected tmux attach). Keep the // snapshot available for manual continuation, but never let the // ownership-deferred path synthesize an agent resume command on // top of that binding. - let restorableAgentCanAutoResume = restorableAgent != nil && + let restorableAgentCanAutoResume = retainedRestorableAgent != nil && (resumeBinding == nil || resumeBinding?.isAgentHookBinding == true) let shouldCheckAgentOwnership = shouldAutoResumeAgent && + !restoresRemoteWorkspaceTerminalSnapshot && (restorableAgentCanAutoResume || resumeBinding?.isAgentHookBinding == true) let restoreAgentIndex = shouldCheckAgentOwnership ? restorableAgentIndex : nil let restoreIndexUnavailable = shouldCheckAgentOwnership && restoreAgentIndex == nil @@ -1683,16 +1392,41 @@ extension Workspace { promptForApproval: true, approvalStoreURL: SurfaceResumeApprovalStore.defaultURL() ) + // An unavailable agent policy deliberately clears the binding's + // launch recipe. Approval evaluation may therefore return nil even + // though the authenticated persistent-SSH transport is still safe + // to reattach. Preserve that transport-only path without reviving + // any stored startup input. + let persistentSSHBindingForStartup = effectiveResumeBindingForStartup ?? { + guard let resumeBinding, + resumeBinding.permitsTransportOnlyPersistentSSHRestore else { + return nil + } + // Keep the authenticated PTY reattach, but make the fallback + // itself an unavailable restore recipe. Passing the original + // exact binding here would let `persistentSSHResumeCommand` + // combine it with the retained agent snapshot and replay agent + // startup input even when auto-resume/approval explicitly + // rejected that launch. + return resumeBinding.invalidatingAgentRestoreRecipe() + }() let restoredPersistentSSHResumeCommand: String? = if let restoredRemotePTYSessionID { persistentSSHResumeCommand( - for: effectiveResumeBindingForStartup, + for: persistentSSHBindingForStartup, expectedWorkspaceID: restoredResumeSnapshotWorkspaceID, expectedSurfaceID: snapshot.id, - persistentPTYSessionID: restoredRemotePTYSessionID + persistentPTYSessionID: restoredRemotePTYSessionID, + restorableAgent: retainedRestorableAgent ) } else { nil } + let restoredPersistentSSHHasAgentStartupInput = + restoredPersistentSSHResumeCommand != nil && + effectiveResumeBindingForStartup?.isAgentHookBinding == true && + effectiveResumeBindingForStartup?.remoteStartupInput( + registration: retainedRestorableAgent?.registration + ) != nil let deferredPersistentSSHResumeCommand: String? = if restoreIndexUnavailable, restoresRemoteWorkspaceTerminalSnapshot, restorableAgent == nil, @@ -1732,12 +1466,12 @@ extension Workspace { } else { nil } - let effectiveResumeBinding = unresolvedBindingLaunch != nil || restoredPersistentSSHResumeCommand != nil + let effectiveResumeBinding = unresolvedBindingLaunch != nil || effectivePersistentSSHResumeCommand != nil ? resumeBinding : nil let savedWorkingDirectory = effectiveResumeBinding?.cwd ?? (restoresUntrustedSavedDirectory ? nil : snapshot.terminal?.workingDirectory) - ?? (restoresUntrustedSavedDirectory ? nil : restorableAgent?.workingDirectory) + ?? (restoresUntrustedSavedDirectory ? nil : retainedRestorableAgent?.workingDirectory) ?? (restoresUntrustedSavedDirectory ? nil : snapshot.directory) let workingDirectory = savedWorkingDirectory ?? (restoresUntrustedSavedDirectory ? nil : currentDirectory) @@ -1748,18 +1482,31 @@ extension Workspace { if unresolvedBindingLaunch != nil { return effectiveResumeBindingForStartup?.cwd ?? workingDirectory } - guard let restorableAgent else { return savedWorkingDirectory } - if restorableAgent.registration?.cwd == .ignore { + guard let retainedRestorableAgent else { return savedWorkingDirectory } + if retainedRestorableAgent.registration?.cwd == .ignore { return nil } if restoresRemoteWorkspaceTerminalSnapshot { - return workingDirectory + return retainedRestorableAgent.restoreWorkingDirectorySelection?.resolved( + snapshotWorkingDirectory: retainedRestorableAgent.workingDirectory, + launchWorkingDirectory: retainedRestorableAgent.launchCommand?.workingDirectory + ) } - return restorableAgent.workingDirectory - ?? restorableAgent.launchCommand?.workingDirectory + return retainedRestorableAgent.workingDirectory + ?? retainedRestorableAgent.launchCommand?.workingDirectory ?? workingDirectory }() + let canAttemptAgentResumeLaunch = !restoresRemoteWorkspaceTerminalSnapshot || + remoteRestoreWorkingDirectorySelection?.permitsResume != false + let restorableAgentForContinuation = + canAttemptAgentResumeLaunch || effectivePersistentSSHResumeCommand != nil + ? retainedRestorableAgent + : nil let restoredBindingLaunch = unresolvedBindingLaunch + // Check the pure command builder before taking the dedup claim. A + // retained snapshot may be present but non-renderable (for example + // an unavailable custom restore recipe). + let canRenderRestoredAgentResume = retainedRestorableAgent?.resumeCommand != nil let restorableTmuxStartCommand = localTmuxStartCommand ?? (!restoreStartupBlocked && liveSessionOwner == nil && @@ -1785,41 +1532,52 @@ extension Workspace { // retain the in-app claim below. var remoteRestoreClaim: AgentResumeLaunchGuard.Claim? let agentSessionAlreadyActive: Bool = { - guard shouldAutoResumeAgent, restorableAgentCanAutoResume, - restoredHibernation == nil, restoredBindingLaunch == nil, - let restorableAgent else { + guard canAttemptAgentResumeLaunch, + shouldAutoResumeAgent, + restorableAgentCanAutoResume, + canRenderRestoredAgentResume, + restoredHibernation == nil, + restoredBindingLaunch == nil, + let retainedRestorableAgent else { return false } if restoreIndexUnavailable { // The off-main index refresh will resolve this staged panel. return true } - guard let restoreAgentIndex else { return true } - if restoreStartupBlocked { - // A conflicting live owner must suppress this launch even - // when the persisted session is not the selected entry. - return true + // Remote restores (and other paths that deliberately opt out + // of local ownership checking) have no index by design. The + // absence of a local census is not evidence that the remote + // session is already active; let the launch claim guard below + // provide the in-process duplicate protection instead. + if shouldCheckAgentOwnership { + guard let restoreAgentIndex else { return true } + if restoreStartupBlocked { + // A conflicting live owner must suppress this launch even + // when the persisted session is not the selected entry. + return true + } + if restoreAgentIndex.hasCurrentAmbiguousPanel( + snapshot.id, + revalidateProcessEvidence: false + ) { + // Do not launch while panel ownership is ambiguous; a live process + // may still be attached to another owner record. + return true + } + if restoreAgentIndex.hasCurrentLiveProcessForStablePanel( + workspaceId: id, + panelId: snapshot.id, + expectedKind: retainedRestorableAgent.kind.rawValue, + expectedSessionId: retainedRestorableAgent.sessionId, + revalidateProcessEvidence: false + ) { + return true + } } if liveSessionOwner != nil { return true } - if restoreAgentIndex.hasCurrentAmbiguousPanel( - snapshot.id, - revalidateProcessEvidence: false - ) { - // Do not launch while panel ownership is ambiguous; a live process - // may still be attached to another owner record. - return true - } - if restoreAgentIndex.hasCurrentLiveProcessForStablePanel( - workspaceId: id, - panelId: snapshot.id, - expectedKind: restorableAgent.kind.rawValue, - expectedSessionId: restorableAgent.sessionId, - revalidateProcessEvidence: false - ) { - return true - } // Local restores converge on the CLI admission RPC immediately // before exec. Remote compatibility launches cannot use that // local socket boundary, so they retain the in-app tie-breaker. @@ -1827,29 +1585,49 @@ extension Workspace { return false } remoteRestoreClaim = AgentResumeLaunchGuard.shared.claimResumeLaunchWithToken( - kind: restorableAgent.kind.rawValue, - sessionId: restorableAgent.sessionId + kind: retainedRestorableAgent.kind.rawValue, + sessionId: retainedRestorableAgent.sessionId ) return remoteRestoreClaim == nil }() - let restoredAgentResumeLaunch: SurfaceResumeStartupLaunch? = - if shouldAutoResumeAgent && restorableAgentCanAutoResume, - restoredHibernation == nil && restoredBindingLaunch == nil - && !agentSessionAlreadyActive { - if restoresRemoteWorkspaceTerminalSnapshot { - restorableAgent?.resumeStartupInput( - useLocalRestoreVerb: false, - restoringWorkingDirectory: resumeSessionWorkingDirectory + let restoredAgentResumeLaunch: SurfaceResumeStartupLaunch? = { + guard canAttemptAgentResumeLaunch, + shouldAutoResumeAgent, + restorableAgentCanAutoResume, + restoredHibernation == nil, + restoredBindingLaunch == nil, + !agentSessionAlreadyActive, + let retainedRestorableAgent else { + return nil + } + let startupInput = restoresRemoteWorkspaceTerminalSnapshot + ? retainedRestorableAgent.remoteResumeStartupInput() + : retainedRestorableAgent.resumeStartupInput( + restoringWorkingDirectory: resumeSessionWorkingDirectory + ) + guard let startupInput, + !startupInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + // The claim is only useful when a launch was actually + // rendered. Release it immediately on command/script + // construction failure so a later restore is not blocked + // for the claim TTL. + if let claim = remoteRestoreClaim { + _ = AgentResumeLaunchGuard.shared.releaseResumeLaunch( + kind: retainedRestorableAgent.kind.rawValue, + sessionId: retainedRestorableAgent.sessionId, + claim: claim ) - .map(SurfaceResumeStartupLaunch.input) + remoteRestoreClaim = nil } else { - restorableAgent?.resumeStartupInput( - restoringWorkingDirectory: resumeSessionWorkingDirectory - ).map(SurfaceResumeStartupLaunch.input) + AgentResumeLaunchGuard.shared.releaseResumeLaunch( + kind: retainedRestorableAgent.kind.rawValue, + sessionId: retainedRestorableAgent.sessionId + ) } - } else { - nil + return nil } + return .input(startupInput) + }() if restoredAgentResumeLaunch == nil, let unusedClaim = remoteRestoreClaim, let restorableAgent { @@ -1873,14 +1651,11 @@ extension Workspace { let deferredAgentResumeCandidateInput: String? = if restoreIndexUnavailable, restoredHibernation == nil, restorableAgentCanAutoResume || resumeBinding?.isAgentHookBinding == true { - if let restorableAgent { + if let retainedRestorableAgent { if restoresRemoteWorkspaceTerminalSnapshot { - restorableAgent.resumeStartupInput( - useLocalRestoreVerb: false, - restoringWorkingDirectory: resumeSessionWorkingDirectory - ) + retainedRestorableAgent.remoteResumeStartupInput() } else { - restorableAgent.resumeStartupInput( + retainedRestorableAgent.resumeStartupInput( restoringWorkingDirectory: resumeSessionWorkingDirectory ) } @@ -1909,7 +1684,7 @@ extension Workspace { ).isEmpty == false ? deferredAgentResumeCandidateInput : nil let deferredAgentResumeAdmission = deferredAgentResumeStartupInput != nil let shouldReplayScrollback = sessionRestorePolicy.shouldReplaySessionScrollback( - hasRestorableAgent: restorableAgent != nil, + hasRestorableAgent: restorableAgentForContinuation != nil, tmuxStartCommand: restoredTmuxStartCommand, hasResumeStartupWork: restoredBindingLaunch != nil || restoredAgentResumeLaunch != nil || deferredAgentResumeStartupInput != nil @@ -1969,7 +1744,8 @@ extension Workspace { localWorkingDirectory ?? hostShellWorkingDirectory let restoredAgentWillRunStartupCommand = effectivePersistentSSHResumeCommand != nil && - resumeBinding?.isAgentHookBinding == true + restoredPersistentSSHHasAgentStartupInput && + (effectiveResumeBindingForStartup ?? resumeBinding)?.isAgentHookBinding == true let restoredAgentWillRunStartupInput = restoredAgentResumeLaunch?.initialInput != nil || (restoredBindingLaunch?.initialInput != nil && resumeBinding?.isAgentHookBinding == true) || @@ -2083,7 +1859,19 @@ extension Workspace { restoredPanelId: terminalPanel.id ) } - if let storedResumeBinding = effectiveResumeBindingForStartup ?? resumeBinding { + // A rejected agent reconstruction must not leave its hook binding + // behind as a less-restrictive manual restore path. Persistent SSH + // reattach keeps the continuation snapshot above, so its binding is + // retained while its captured startup input remains suppressed. + // A process-detected/plain SSH binding is independent of that + // agent recipe and must remain available for terminal reattach. + let shouldDropRejectedAgentBinding = restorableAgent != nil && + restorableAgentForContinuation == nil && + resumeBinding?.isAgentHookBinding == true + let resumeBindingForRetention = shouldDropRejectedAgentBinding + ? nil + : (effectiveResumeBindingForStartup ?? resumeBinding) + if let storedResumeBinding = resumeBindingForRetention { let restoredBinding = storedResumeBinding.retargetingRemoteOwner( expectedWorkspaceID: restoredResumeSnapshotWorkspaceID, expectedSurfaceID: snapshot.id, @@ -2097,6 +1885,13 @@ extension Workspace { ) { surfaceResumeBindingsByPanelId[terminalPanel.id] = restoredBinding } + if let restoredAgent = restoredBinding.managedRestorableAgentSnapshot( + replacing: restorableAgentForContinuation, + previousBinding: resumeBinding + ) { + restoredAgentLifecycle.setSnapshot(restoredAgent, panelId: terminalPanel.id) + invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: terminalPanel.id) + } if restoredBinding.isPlainSSHProcessDetectedBinding, restoredBindingLaunch != nil { pendingPlainSSHRestorePanelIds.insert(terminalPanel.id) @@ -2140,9 +1935,13 @@ extension Workspace { } terminalStartupRestoreCoordinator.stage( panel: terminalPanel, - snapshot: restorableAgent, - resumeBinding: resumeBinding, - manualResumeAvailable: restorableAgent != nil, + // Stage the policy-constrained snapshot. The raw persisted + // snapshot may contain a local launch cwd; retaining it here + // would let the lifecycle/restore-record path reintroduce that + // cwd after the remote trust decision has already stripped it. + snapshot: restorableAgentForContinuation, + resumeBinding: resumeBindingForRetention, + manualResumeAvailable: restorableAgentForContinuation != nil, willRunStartupCommand: restoredAgentWillRunStartupCommand, willRunStartupInput: restoredAgentWillRunStartupInput, resumeWorkingDirectory: restoredDirectoryIsLocalPath @@ -2171,8 +1970,8 @@ extension Workspace { panelId: terminalPanel.id, restore: DeferredAgentResumeRestore( stablePanelID: snapshot.id, - restorableAgent: restorableAgent, - resumeBinding: resumeBinding, + restorableAgent: retainedRestorableAgent, + resumeBinding: resumeBindingForRetention, restoresRemoteWorkspaceTerminalSnapshot: restoresRemoteWorkspaceTerminalSnapshot, remoteResumeContext: surfaceResumeBindingsByPanelId[terminalPanel.id]?.launchFlavor.remoteContext, remoteResumeCommandEmbedded: deferredPersistentSSHResumeCommand != nil, @@ -2181,11 +1980,11 @@ extension Workspace { ) ) } - if let restorableAgent { + if let restorableAgentForContinuation { if let restoredHibernation, - restorableAgent.resumeCommand != nil { - terminalPanel.enterAgentHibernation( - agent: restorableAgent, + restorableAgentForContinuation.resumeCommand != nil { + _ = terminalPanel.enterAgentHibernation( + agent: restorableAgentForContinuation, lastActivityAt: Date(timeIntervalSince1970: restoredHibernation.lastActivityAt), hibernatedAt: Date(timeIntervalSince1970: restoredHibernation.hibernatedAt) ) @@ -2449,7 +2248,7 @@ extension Workspace { syncUnreadBadgeStateForAllPanels() } - private func notificationSnapshots(surfaceId: UUID?) -> [SessionNotificationSnapshot] { + func notificationSnapshots(surfaceId: UUID?) -> [SessionNotificationSnapshot] { AppDelegate.shared?.notificationStore? .notifications(forTabId: id, surfaceId: surfaceId) .map(SessionNotificationSnapshot.init(notification:)) ?? [] @@ -3173,6 +2972,10 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos private var remoteRelayWorkspaceIDAliases: [UUID: UUID] = [:] private var remoteRelaySurfaceIDAliases: [UUID: UUID] = [:] private var suppressRemoteTerminalStartupForSessionRestoreScaffold = false + /// True while a session snapshot is rebuilding its topology. Hibernated + /// terminals must not be resumed by focus/portal reconciliation during + /// that transaction; they resume only on a later explicit visit. + private var isRestoringSessionSnapshot = false var pendingRemoteTerminalChildExitSurfaceIds: Set = [] struct PendingRemoteDisconnectReplacement { @@ -3257,7 +3060,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos var pendingPlainSSHRestorePanelIds: Set = [] var observedPlainSSHPanelIds: Set = [] var plainSSHDetectionMissesByPanelId: [UUID: Int] = [:] - private static let plainSSHRestoreObservationMissLimit = 3 + static let plainSSHRestoreObservationMissLimit = 3 var restoredGuardedWorkingDirectoriesByPanelId: [UUID: RestoredWorkingDirectoryGuard] = [:] /// The session directory each restored auto-resume launcher targets, kept /// for the resumed run so split/new-tab cwd inheritance can rescue a @@ -3342,7 +3145,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos || lowered.contains("daemon transport") } - private static func isProxyOnlyRemoteLogEntry(_ entry: SidebarLogEntry) -> Bool { + static func isProxyOnlyRemoteLogEntry(_ entry: SidebarLogEntry) -> Bool { entry.source == "remote-proxy" || isProxyOnlyRemoteError(entry.message) } @@ -6035,6 +5838,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos @discardableResult func resumeAgentHibernation(panelId: UUID, focus: Bool) -> Bool { + guard !isRestoringSessionSnapshot else { return false } guard let terminalPanel = panels[panelId] as? TerminalPanel, terminalPanel.isAgentHibernated else { return false @@ -6073,9 +5877,12 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos @discardableResult func setSurfaceResumeBinding(_ binding: SurfaceResumeBindingSnapshot, panelId: UUID) -> Bool { - guard terminalPanel(for: panelId) != nil, - let startupInput = binding.inlineStartupInput(repairPortableAgentExecutable: false), - !startupInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + guard terminalPanel(for: panelId) != nil else { + return false + } + let startupInput = binding.inlineStartupInput(repairPortableAgentExecutable: false) + guard startupInput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false || + binding.permitsTransportOnlyPersistentSSHRestore else { return false } let activeRestoreClaim = surfaceResumeRestoreClaim(for: panelId) @@ -6100,26 +5907,100 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos by: binding, panelId: panelId ) + let previousBinding = surfaceResumeBindingsByPanelId[panelId] + if binding.isAgentHookBinding, + binding.launchFlavor.remoteContext != nil, + let selection = binding.restoreWorkingDirectorySelection, + selection.discardsRecordedCwdOptions, + let restoredAgent = restoredAgentSnapshotsByPanelId[panelId], + Self.restorableAgentForSessionRestore( + restoredAgent, + resumeBinding: binding + ) != nil { + // An authenticated remote report is authoritative for both persisted + // halves of the restore record; do not leave a stale snapshot policy + // for control-surface restore to prefer later. + restoredAgentLifecycle.setSnapshot( + restoredAgent.applyingAuthoritativeBindingSelection(selection), + panelId: panelId + ) + } + let constrainedBinding: SurfaceResumeBindingSnapshot = if binding.isAgentHookBinding, + binding.launchFlavor.remoteContext != nil, + binding.restoreWorkingDirectorySelection != nil { + // Persistent-SSH selections are introduced only after relay + // authentication or loaded from persisted remote state. A fresh + // authenticated report must replace stale same-session policy. + binding + } else if binding.isAgentHookBinding, + // A retained snapshot may carry a remote-only cwd policy. + // Apply it to a refresh only while the binding stays in the + // same execution location; a local hook refresh starts a new + // restore scope and must not inherit remote state. + previousBinding?.launchFlavor.representsSameExecutionLocation( + as: binding.launchFlavor + ) == true, + let restoredAgent = + restoredAgentSnapshotsByPanelId[panelId], + let selection = restoredAgent.restoreWorkingDirectorySelection, + Self.restorableAgentForSessionRestore( + restoredAgent, + resumeBinding: binding + ) != nil { + binding.applyingRestoreWorkingDirectorySelection( + selection, + from: restoredAgent + ) + } else if let previousBinding, + previousBinding.isSameManagedSession(as: binding), + previousBinding.launchFlavor.representsSameExecutionLocation( + as: binding.launchFlavor + ) { + binding.inheritingRestoreWorkingDirectorySelection(from: previousBinding) + } else { + binding + } // This transient cwd belongs to the binding restored at launch. Let a // same-session hook refresh keep its cwd rescue, but never let it // override a replacement session's structured restore record. - if let previous = surfaceResumeBindingsByPanelId[panelId], - previous.kind != binding.kind - || previous.checkpointId != binding.checkpointId - || previous.cwd != binding.cwd - || previous.launchCommand?.workingDirectory != binding.launchCommand?.workingDirectory - || (previous.launchCommand == nil && binding.launchCommand == nil - && previous.command != binding.command) { + if let previous = previousBinding, + previous.kind != constrainedBinding.kind + || previous.checkpointId != constrainedBinding.checkpointId + || previous.cwd != constrainedBinding.cwd + || previous.launchCommand?.workingDirectory != constrainedBinding.launchCommand?.workingDirectory + || (previous.launchCommand == nil && constrainedBinding.launchCommand == nil + && previous.command != constrainedBinding.command) { restoredResumeSessionWorkingDirectoriesByPanelId.removeValue(forKey: panelId) } - if let restorableAgent = binding.managedRestorableAgentSnapshot( - replacing: previousRestorableAgent - ) { + // A same-session hook refresh may omit cwd/launch fields. Preserve the + // policy-constrained snapshot already associated with that session + // instead of rebuilding it from the refresh's captured cwd (which can + // be local while the terminal is remote). + let bindingContinuesPreviousAgent = if let previousRestorableAgent, + let incomingKind = constrainedBinding.kind, + let incomingSessionID = constrainedBinding.checkpointId { + previousRestorableAgent.kind.rawValue == incomingKind && + ManagedAgentSessionIdentity.sessionIDsMatch( + kind: incomingKind, + lhs: previousRestorableAgent.sessionId, + rhs: incomingSessionID + ) && + previousBinding?.launchFlavor.representsSameExecutionLocation( + as: constrainedBinding.launchFlavor + ) == true + } else { + false + } + if !bindingContinuesPreviousAgent, + let restorableAgent = constrainedBinding.managedRestorableAgentSnapshot( + replacing: previousRestorableAgent, + previousBinding: previousBinding + ) { restoredAgentLifecycle.setSnapshot(restorableAgent, panelId: panelId) invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: panelId) } - surfaceResumeBindingsByPanelId[panelId] = binding - if binding.isPlainSSHProcessDetectedBinding { + surfaceResumeBindingsByPanelId[panelId] = constrainedBinding + if constrainedBinding.isPlainSSHProcessDetectedBinding { observedPlainSSHPanelIds.insert(panelId) pendingPlainSSHRestorePanelIds.remove(panelId) plainSSHDetectionMissesByPanelId[panelId] = 0 @@ -6201,7 +6082,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos guard let currentBinding = surfaceResumeBindingsByPanelId[panelId], currentBinding.checkpointId == claim.binding.checkpointId, currentBinding.source == claim.binding.source, - currentBinding.updatedAt == claim.binding.updatedAt else { + currentBinding.updatedAt == claim.binding.updatedAt, + currentBinding.launchFlavor == claim.binding.launchFlavor else { // A direct lifecycle mutation replaced the claimed generation // without going through the hook setter. Do not let that old claim // block a later, legitimate binding. @@ -11333,7 +11215,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos previousHostedView: GhosttySurfaceScrollView? = nil, trigger: FocusPanelTrigger = .standard, focusIntent: PanelFocusIntent? = nil, - focusTransactionId: UUID? = nil + focusTransactionId: UUID? = nil, + resumeHibernatedAgent: Bool = true ) { guard !remoteTmuxMirrorInterceptsFocusPanel(panelId, previousHostedView: previousHostedView, trigger: trigger, focusIntent: focusIntent) else { return } let effectiveFocusTransactionId = focusTransactionId ?? activeFocusTransactionId @@ -11416,6 +11299,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos inPane: targetPaneId, reassertAppKitFocus: false, focusIntent: activationIntent, + resumeHibernatedAgent: resumeHibernatedAgent, focusTransactionId: effectiveFocusTransactionId, previousTerminalHostedView: previousTerminalHostedView ) @@ -11454,7 +11338,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos inPane: targetPaneId, reassertAppKitFocus: !shouldSuppressReentrantRefocus, focusIntent: activationIntent, - resumeHibernatedAgent: true, + resumeHibernatedAgent: resumeHibernatedAgent, focusTransactionId: effectiveFocusTransactionId, previousTerminalHostedView: previousTerminalHostedView ) @@ -11631,7 +11515,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos func setAgentHibernationAutoResumePresentationVisible(_ isVisible: Bool) { guard agentHibernationAutoResumePresentationVisible != isVisible else { return } agentHibernationAutoResumePresentationVisible = isVisible - guard isVisible else { return } + guard isVisible, !isRestoringSessionSnapshot else { return } _ = resumeVisibleAgentHibernationPanels(panelIds: agentHibernationVisiblePanelIdsForCurrentLayout()) } @@ -12367,7 +12251,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos // this workspace's focused panel — mirroring the SwiftUI `isFocused` gate so // a layout reconcile cannot steal focus back from the sidebar. let rightSidebarOwnsFocus = AppDelegate.shared?.rightSidebarOwnsInputFocus(for: self) ?? false - var didChange = agentHibernationAutoResumePresentationVisible + var didChange = !isRestoringSessionSnapshot && agentHibernationAutoResumePresentationVisible ? resumeVisibleAgentHibernationPanels(panelIds: visiblePanelIds) : false @@ -13421,7 +13305,8 @@ extension Workspace: BonsplitDelegate { } // Selecting a hibernated tab means the user is visiting it again. Resume by // default so sidebar/tab selection behaves the same as pressing Resume. - let shouldResumeHibernatedAgent = resumeHibernatedAgent ?? true + let shouldResumeHibernatedAgent = !isRestoringSessionSnapshot && + (resumeHibernatedAgent ?? true) let activationIntent = focusIntent ?? activationPanel.preferredFocusIntentForActivation() activationPanel.prepareFocusIntentForActivation(activationIntent) let panelId = effectiveFocusedPanelId @@ -13465,6 +13350,7 @@ extension Workspace: BonsplitDelegate { activationPanel, focusIntent: activationIntent, reassertAppKitFocus: reassertAppKitFocus, + resumeHibernatedAgent: shouldResumeHibernatedAgent, focusTransactionId: transactionId ) let focusIntentAllowsBrowserOmnibarAutofocus = @@ -13519,7 +13405,10 @@ extension Workspace: BonsplitDelegate { isVisibleInUI: true, reason: "workspace.restoreFocusIntent" ) - } else if shouldRestoreFocusIntentAfterActivation(activationIntent) { + } else if shouldRestoreFocusIntentAfterActivation(activationIntent), + !(activationPanel is TerminalPanel && + (activationPanel as? TerminalPanel)?.isAgentHibernated == true && + !shouldResumeHibernatedAgent) { _ = activationPanel.restoreFocusIntent(activationIntent) } @@ -13564,6 +13453,7 @@ extension Workspace: BonsplitDelegate { _ panel: any Panel, focusIntent: PanelFocusIntent, reassertAppKitFocus: Bool, + resumeHibernatedAgent: Bool = true, focusTransactionId: UUID? = nil ) { // Bonsplit invokes selection callbacks synchronously while a session @@ -13586,7 +13476,8 @@ extension Workspace: BonsplitDelegate { let shouldFocusTerminalSurface = shouldMoveTerminalSurfaceFocus(for: focusIntent) terminalPanel.surface.setFocus(shouldFocusTerminalSurface) terminalPanel.hostedView.setActive(true) - if reassertAppKitFocus && shouldFocusTerminalSurface { + if reassertAppKitFocus && shouldFocusTerminalSurface && + (resumeHibernatedAgent || !terminalPanel.isAgentHibernated) { terminalPanel.focus(focusTransactionId: focusTransactionId) } return diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index e62fd48a63d0..74aa727d13e5 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -664,7 +664,7 @@ B900004CA1B2C3D4E5F60719 /* CLISocketPathResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900004BA1B2C3D4E5F60719 /* CLISocketPathResolver.swift */; }; C12985000000000000000002 /* CLISocketSentryTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12985000000000000000001 /* CLISocketSentryTelemetry.swift */; }; A8D837B3AA85F4353C493DE1 /* CLISSHPTYAttachProbeReplyRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB1737C956A80E5F98E9DC5 /* CLISSHPTYAttachProbeReplyRegressionTests.swift */; }; - D0F102000000000000000001 /* CLISSHPTYAttachReplayBoundaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F102000000000000000002 /* CLISSHPTYAttachReplayBoundaryTests.swift */; }; + F1BD828B7BB144DD87F4D2D7 /* CLISSHPTYAttachReplayBoundaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F102000000000000000002 /* CLISSHPTYAttachReplayBoundaryTests.swift */; }; C73660020000000000000001 /* CLISSHPTYAttachSessionLostRespawnTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C73660020000000000000002 /* CLISSHPTYAttachSessionLostRespawnTests.swift */; }; C77070000000000000000004 /* CLISSHPTYAttachTransientBridgeFailureExitCodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C77070000000000000000005 /* CLISSHPTYAttachTransientBridgeFailureExitCodeTests.swift */; }; 6379A0016379A0016379A001 /* CLISSHPTYResizeInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6379A0026379A0026379A002 /* CLISSHPTYResizeInputTests.swift */; }; @@ -1525,6 +1525,7 @@ BE979B868F4D42199E5400E2 /* ControlSidebarPanelOwner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8746A8206B6142D7B963CDFA /* ControlSidebarPanelOwner.swift */; }; C51A73B30000000000000002 /* ControlSimulatorPendingTextInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A73B30000000000000001 /* ControlSimulatorPendingTextInput.swift */; }; D10000000000000000B00000 /* ControlSocketReadinessUITestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000B00001 /* ControlSocketReadinessUITestSupport.swift */; }; + 2430809C88169B7568E0325D /* ControlSurfaceResumeTarget+BindingSanitization.swift in Sources */ = {isa = PBXBuildFile; fileRef = C082F12A68F721A58BCF82A4 /* ControlSurfaceResumeTarget+BindingSanitization.swift */; }; 11295000000000000000000E /* ControlSurfaceResumeTarget+ContinuationRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11295000000000000000000D /* ControlSurfaceResumeTarget+ContinuationRecord.swift */; }; D8684000000000000000000F /* ControlSurfaceResumeTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86840000000000000000010 /* ControlSurfaceResumeTarget.swift */; }; 93300005A1B2C3D4E5F60718 /* ControlTerminalSocketBindingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93300006A1B2C3D4E5F60718 /* ControlTerminalSocketBindingState.swift */; }; @@ -2120,7 +2121,7 @@ B8B056D80000000000000001 /* MobileHostIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B056D80000000000000002 /* MobileHostIdentityTests.swift */; }; C1A071000000000000000001 /* MobileHostIrohAdmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A071000000000000000011 /* MobileHostIrohAdmissionTests.swift */; }; A17070900000000000000002 /* MobileHostIrohApplicationLaneRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17070900000000000000001 /* MobileHostIrohApplicationLaneRouter.swift */; }; - C1B1810000000000000005 /* MobileHostIrohRuntime+Activation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B1810000000000000025 /* MobileHostIrohRuntime+Activation.swift */; }; + 225FCDA90B754A7CBAF13BF7 /* MobileHostIrohRuntime+Activation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B1810000000000000025 /* MobileHostIrohRuntime+Activation.swift */; }; C1B1810000000000000006 /* MobileHostIrohRuntime+Lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B1810000000000000026 /* MobileHostIrohRuntime+Lifecycle.swift */; }; C1B1810000000000000007 /* MobileHostIrohRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B1810000000000000027 /* MobileHostIrohRuntime.swift */; }; C1A070000000000000000004 /* MobileHostIrohServerEventWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A070000000000000000014 /* MobileHostIrohServerEventWriter.swift */; }; @@ -2397,6 +2398,10 @@ 9666F3019666F3019666F301 /* RecoverableMainWindowRoutePayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9666F3029666F3029666F302 /* RecoverableMainWindowRoutePayload.swift */; }; A6D1F0000000000000000002 /* ReleasingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6D1F0000000000000000001 /* ReleasingWindowController.swift */; }; A6D1F0400000000000000002 /* ReleasingWindowControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6D1F0400000000000000001 /* ReleasingWindowControllerTests.swift */; }; + E3F39991CD8030E1EF9DB755 /* RemoteAgentRestoreWorkingDirectoryTests+Hibernation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDF7ACE675F78C970068F60 /* RemoteAgentRestoreWorkingDirectoryTests+Hibernation.swift */; }; + 66473DD776CF7BB08E750542 /* RemoteAgentRestoreWorkingDirectoryTests+Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27B72A450B64B509BE13A199 /* RemoteAgentRestoreWorkingDirectoryTests+Policy.swift */; }; + DF0D740B6BC7E8501EDAC910 /* RemoteAgentRestoreWorkingDirectoryTests+Remote.swift in Sources */ = {isa = PBXBuildFile; fileRef = E857AD1B188E696019694C62 /* RemoteAgentRestoreWorkingDirectoryTests+Remote.swift */; }; + 83F2D829F6B1191168A215E9 /* RemoteAgentRestoreWorkingDirectoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE7878FAD5244615E0C8997 /* RemoteAgentRestoreWorkingDirectoryTests.swift */; }; 63A6792ED28244D28F5E9A83 /* RemoteClaudeTeamsRespawnRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C95FA2E4CBC042628EE67957 /* RemoteClaudeTeamsRespawnRoutingTests.swift */; }; EE30D5000000000000000002 /* RemoteDaemonStrings+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE30D5000000000000000001 /* RemoteDaemonStrings+App.swift */; }; 791100030000000000000001 /* RemoteDisconnectLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 791100040000000000000001 /* RemoteDisconnectLifecycleTests.swift */; }; @@ -2414,6 +2419,8 @@ B11049000000000000000003 /* RemoteRelayTmuxCompatAuthorizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11049000000000000000004 /* RemoteRelayTmuxCompatAuthorizationTests.swift */; }; A5001640 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; + 2960A8C19B06F3ED0B05840A /* RemoteResumeBindingLegacyPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D74ABD05E11E6AF858BF0D5 /* RemoteResumeBindingLegacyPolicyTests.swift */; }; + DCA0DF77A4592C7BA46E8DAE /* RemoteResumeBindingRestorePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9872CEFBD0DE96663DF2A372 /* RemoteResumeBindingRestorePolicyTests.swift */; }; 7989A0017989A0017989A001 /* RemoteResumeBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7989A0027989A0027989A002 /* RemoteResumeBindingTests.swift */; }; REE0CA0000000000000000D2 /* RemoteRouteSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = REE0CA0000000000000000D1 /* RemoteRouteSpec.swift */; }; REE0CA0000000000000000A2 /* RemotesClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = REE0CA0000000000000000A1 /* RemotesClient.swift */; }; @@ -2680,7 +2687,9 @@ A74770010000000000000009 /* SessionPersistencePolicy+ConfigFrames.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7477001000000000000000A /* SessionPersistencePolicy+ConfigFrames.swift */; }; C65930020000000000000002 /* SessionPersistencePolicy+CrashStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C65930020000000000000001 /* SessionPersistencePolicy+CrashStorage.swift */; }; F6572002A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572003A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift */; }; + AE3C5D0D17958A0195CABE9F /* SessionPersistenceResumeWorkingDirectoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2B3DC562A0000B7F95DD2C5 /* SessionPersistenceResumeWorkingDirectoryTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + 441CD4F970F80399FA96EE4D /* SessionPersistenceWorkingDirectoryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EEC7989DC889A39ECE901C7 /* SessionPersistenceWorkingDirectoryPolicyTests.swift */; }; 812600000000000000000003 /* SessionRemoteWorkspaceMoshRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 812600000000000000000004 /* SessionRemoteWorkspaceMoshRestoreTests.swift */; }; E30780000000000000000014 /* SessionRemoteWorkspaceSnapshot+Restore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30780000000000000000013 /* SessionRemoteWorkspaceSnapshot+Restore.swift */; }; 0A1107110000000000000022 /* SessionRestorableAgentSnapshot+Commands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1107110000000000000023 /* SessionRestorableAgentSnapshot+Commands.swift */; }; @@ -3048,6 +3057,7 @@ 91007F48E782474596D8FB7D /* SurfaceResource+CloudTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E2A1163D9B452EA3D55B5D /* SurfaceResource+CloudTitle.swift */; }; F96CAE8A14774122A5F8613B /* SurfaceResourceDragPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48A1307BCCDB493F49179E0B /* SurfaceResourceDragPayload.swift */; }; 3E66E7A1B2B320EC850BB174 /* SurfaceResourceDragRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 627B5FB396A1BF22407E8B45 /* SurfaceResourceDragRegistry.swift */; }; + 3BA4A4EBE25DCDE8749844FA /* SurfaceResumeAgentBindingGenerationTests+Continuation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6B83CBA845BB1506D9C94AB /* SurfaceResumeAgentBindingGenerationTests+Continuation.swift */; }; 842300000000000000000007 /* SurfaceResumeAgentBindingGenerationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842300000000000000000008 /* SurfaceResumeAgentBindingGenerationTests.swift */; }; F6572032A1B2C3D4E5F60718 /* SurfaceResumeAgentHookDowngradeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572033A1B2C3D4E5F60718 /* SurfaceResumeAgentHookDowngradeTests.swift */; }; 883700000000000000000003 /* SurfaceResumeApprovalLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 883700000000000000000004 /* SurfaceResumeApprovalLookup.swift */; }; @@ -3057,6 +3067,8 @@ F0ACC0DE000000000000000F /* SurfaceResumeBindingIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACC0DE0000000000000010 /* SurfaceResumeBindingIndex.swift */; }; 8993B0018993B0018993B001 /* SurfaceResumeBindingSnapshot+ManagedSessionIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8993B0018993B0018993B002 /* SurfaceResumeBindingSnapshot+ManagedSessionIdentity.swift */; }; 7989B0017989B0017989B001 /* SurfaceResumeBindingSnapshot+Remote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7989B1017989B1017989B101 /* SurfaceResumeBindingSnapshot+Remote.swift */; }; + 5F0DD396484DE282F679ED81 /* SurfaceResumeBindingSnapshot+RestoreInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0585E5669F0C3945B3BAB02 /* SurfaceResumeBindingSnapshot+RestoreInput.swift */; }; + 49DEF72DC12ED60862183BC0 /* SurfaceResumeBindingSnapshot+WorkingDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06EBC8AC18D6CB2E0C10CBA8 /* SurfaceResumeBindingSnapshot+WorkingDirectory.swift */; }; F6572010A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+CodexUpdateCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572011A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+CodexUpdateCheck.swift */; }; 11295000000000000000000C /* SurfaceResumeCommandCanonicalizer+Fork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11295000000000000000000B /* SurfaceResumeCommandCanonicalizer+Fork.swift */; }; F6572000A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572001A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift */; }; @@ -3371,6 +3383,7 @@ A5001543 /* TerminalSSHSessionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001545 /* TerminalSSHSessionDetector.swift */; }; D4A9923A0000000000000001 /* TerminalStartupRestoreCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A9923A0000000000000002 /* TerminalStartupRestoreCoordinator.swift */; }; 99230001992300019923000E /* TerminalStartupRestoreFailureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99230001992300019923000D /* TerminalStartupRestoreFailureTests.swift */; }; + 9855A0000000000000000005 /* TerminalStartupWorkingDirectoryPrefix+SavedOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9855A0000000000000000006 /* TerminalStartupWorkingDirectoryPrefix+SavedOptions.swift */; }; 124760000000000000000009 /* TerminalSurface+CloudImagePaste.swift in Sources */ = {isa = PBXBuildFile; fileRef = 124770000000000000000009 /* TerminalSurface+CloudImagePaste.swift */; }; 12476000000000000000000A /* TerminalSurface+ImageTransferTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12477000000000000000000A /* TerminalSurface+ImageTransferTarget.swift */; }; D36A00090000000000000001 /* TerminalSurface+RendererRealizationSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00090000000000000002 /* TerminalSurface+RendererRealizationSurface.swift */; }; @@ -3627,6 +3640,7 @@ 9200A0019200A0019200A001 /* Workspace+RestoredWorkingDirectoryGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9200A0019200A0019200A002 /* Workspace+RestoredWorkingDirectoryGuard.swift */; }; 381B026EFFAB40449AF805E8 /* Workspace+RightSidebarToolDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52ACFA4197424088998A76C0 /* Workspace+RightSidebarToolDrop.swift */; }; C548600A000000000000001 /* Workspace+SessionRestoreIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C548600A000000000000002 /* Workspace+SessionRestoreIdentity.swift */; }; + EBD244628A0C0FE3E78C03C3 /* Workspace+SessionSnapshotProjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A36A5D72167E9864FC9EBD8D /* Workspace+SessionSnapshotProjection.swift */; }; C7268002000000000000001 /* Workspace+SidebarDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7268002000000000000002 /* Workspace+SidebarDirectories.swift */; }; C3744E100000000000000001 /* Workspace+SidebarStatusVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3744E100000000000000002 /* Workspace+SidebarStatusVisibility.swift */; }; C51A710000000000000000C1 /* Workspace+SimulatorPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A710000000000000000C2 /* Workspace+SimulatorPane.swift */; }; @@ -5301,6 +5315,7 @@ 8746A8206B6142D7B963CDFA /* ControlSidebarPanelOwner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlSidebarPanelOwner.swift; sourceTree = ""; }; C51A73B30000000000000001 /* ControlSimulatorPendingTextInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlSimulatorPendingTextInput.swift; sourceTree = ""; }; D10000000000000000B00001 /* ControlSocketReadinessUITestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlSocketReadinessUITestSupport.swift; sourceTree = ""; }; + C082F12A68F721A58BCF82A4 /* ControlSurfaceResumeTarget+BindingSanitization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ControlSurfaceResumeTarget+BindingSanitization.swift"; sourceTree = ""; }; 11295000000000000000000D /* ControlSurfaceResumeTarget+ContinuationRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ControlSurfaceResumeTarget+ContinuationRecord.swift"; sourceTree = ""; }; D86840000000000000000010 /* ControlSurfaceResumeTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlSurfaceResumeTarget.swift; sourceTree = ""; }; 93300006A1B2C3D4E5F60718 /* ControlTerminalSocketBindingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlTerminalSocketBindingState.swift; sourceTree = ""; }; @@ -6163,6 +6178,10 @@ 9666F3029666F3029666F302 /* RecoverableMainWindowRoutePayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoverableMainWindowRoutePayload.swift; sourceTree = ""; }; A6D1F0000000000000000001 /* ReleasingWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/ReleasingWindowController.swift; sourceTree = ""; }; A6D1F0400000000000000001 /* ReleasingWindowControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReleasingWindowControllerTests.swift; sourceTree = ""; }; + 3CDF7ACE675F78C970068F60 /* RemoteAgentRestoreWorkingDirectoryTests+Hibernation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteAgentRestoreWorkingDirectoryTests+Hibernation.swift"; sourceTree = ""; }; + 27B72A450B64B509BE13A199 /* RemoteAgentRestoreWorkingDirectoryTests+Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteAgentRestoreWorkingDirectoryTests+Policy.swift"; sourceTree = ""; }; + E857AD1B188E696019694C62 /* RemoteAgentRestoreWorkingDirectoryTests+Remote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteAgentRestoreWorkingDirectoryTests+Remote.swift"; sourceTree = ""; }; + 8CE7878FAD5244615E0C8997 /* RemoteAgentRestoreWorkingDirectoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteAgentRestoreWorkingDirectoryTests.swift"; sourceTree = ""; }; C95FA2E4CBC042628EE67957 /* RemoteClaudeTeamsRespawnRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteClaudeTeamsRespawnRoutingTests.swift; sourceTree = ""; }; EE30D5000000000000000001 /* RemoteDaemonStrings+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteDaemonStrings+App.swift"; sourceTree = ""; }; 791100040000000000000001 /* RemoteDisconnectLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDisconnectLifecycleTests.swift; sourceTree = ""; }; @@ -6177,6 +6196,8 @@ B11049000000000000000002 /* RemotePTYReconnectLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePTYReconnectLifecycleTests.swift; sourceTree = ""; }; B11049000000000000000004 /* RemoteRelayTmuxCompatAuthorizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayTmuxCompatAuthorizationTests.swift; sourceTree = ""; }; A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; + 0D74ABD05E11E6AF858BF0D5 /* RemoteResumeBindingLegacyPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteResumeBindingLegacyPolicyTests.swift"; sourceTree = ""; }; + 9872CEFBD0DE96663DF2A372 /* RemoteResumeBindingRestorePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteResumeBindingRestorePolicyTests.swift"; sourceTree = ""; }; 7989A0027989A0027989A002 /* RemoteResumeBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteResumeBindingTests.swift; sourceTree = ""; }; REE0CA0000000000000000D1 /* RemoteRouteSpec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RemoteRouteSpec.swift; sourceTree = ""; }; REE0CA0000000000000000A1 /* RemotesClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RemotesClient.swift; sourceTree = ""; }; @@ -6442,7 +6463,9 @@ A7477001000000000000000A /* SessionPersistencePolicy+ConfigFrames.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionPersistencePolicy+ConfigFrames.swift"; sourceTree = ""; }; C65930020000000000000001 /* SessionPersistencePolicy+CrashStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionPersistencePolicy+CrashStorage.swift"; sourceTree = ""; }; F6572003A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceResumeBindingTests.swift; sourceTree = ""; }; + A2B3DC562A0000B7F95DD2C5 /* SessionPersistenceResumeWorkingDirectoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionPersistenceResumeWorkingDirectoryTests.swift"; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + 0EEC7989DC889A39ECE901C7 /* SessionPersistenceWorkingDirectoryPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionPersistenceWorkingDirectoryPolicyTests.swift"; sourceTree = ""; }; 812600000000000000000004 /* SessionRemoteWorkspaceMoshRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRemoteWorkspaceMoshRestoreTests.swift; sourceTree = ""; }; E30780000000000000000013 /* SessionRemoteWorkspaceSnapshot+Restore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionRemoteWorkspaceSnapshot+Restore.swift"; sourceTree = ""; }; 0A1107110000000000000023 /* SessionRestorableAgentSnapshot+Commands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionRestorableAgentSnapshot+Commands.swift"; sourceTree = ""; }; @@ -6802,6 +6825,7 @@ A7E2A1163D9B452EA3D55B5D /* SurfaceResource+CloudTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResource+CloudTitle.swift"; sourceTree = ""; }; 48A1307BCCDB493F49179E0B /* SurfaceResourceDragPayload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SurfaceResourceDragPayload.swift; sourceTree = ""; }; 627B5FB396A1BF22407E8B45 /* SurfaceResourceDragRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SurfaceResourceDragRegistry.swift; sourceTree = ""; }; + C6B83CBA845BB1506D9C94AB /* SurfaceResumeAgentBindingGenerationTests+Continuation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResumeAgentBindingGenerationTests+Continuation.swift"; sourceTree = ""; }; 842300000000000000000008 /* SurfaceResumeAgentBindingGenerationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeAgentBindingGenerationTests.swift; sourceTree = ""; }; F6572033A1B2C3D4E5F60718 /* SurfaceResumeAgentHookDowngradeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeAgentHookDowngradeTests.swift; sourceTree = ""; }; 883700000000000000000004 /* SurfaceResumeApprovalLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeApprovalLookup.swift; sourceTree = ""; }; @@ -6811,6 +6835,8 @@ F0ACC0DE0000000000000010 /* SurfaceResumeBindingIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeBindingIndex.swift; sourceTree = ""; }; 8993B0018993B0018993B002 /* SurfaceResumeBindingSnapshot+ManagedSessionIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResumeBindingSnapshot+ManagedSessionIdentity.swift"; sourceTree = ""; }; 7989B1017989B1017989B101 /* SurfaceResumeBindingSnapshot+Remote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResumeBindingSnapshot+Remote.swift"; sourceTree = ""; }; + B0585E5669F0C3945B3BAB02 /* SurfaceResumeBindingSnapshot+RestoreInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResumeBindingSnapshot+RestoreInput.swift"; sourceTree = ""; }; + 06EBC8AC18D6CB2E0C10CBA8 /* SurfaceResumeBindingSnapshot+WorkingDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResumeBindingSnapshot+WorkingDirectory.swift"; sourceTree = ""; }; F6572011A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+CodexUpdateCheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResumeCommandCanonicalizer+CodexUpdateCheck.swift"; sourceTree = ""; }; 11295000000000000000000B /* SurfaceResumeCommandCanonicalizer+Fork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResumeCommandCanonicalizer+Fork.swift"; sourceTree = ""; }; F6572001A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift"; sourceTree = ""; }; @@ -7125,6 +7151,7 @@ A5001545 /* TerminalSSHSessionDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSSHSessionDetector.swift; sourceTree = ""; }; D4A9923A0000000000000002 /* TerminalStartupRestoreCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalStartupRestoreCoordinator.swift; sourceTree = ""; }; 99230001992300019923000D /* TerminalStartupRestoreFailureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalStartupRestoreFailureTests.swift; sourceTree = ""; }; + 9855A0000000000000000006 /* TerminalStartupWorkingDirectoryPrefix+SavedOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalStartupWorkingDirectoryPrefix+SavedOptions.swift"; sourceTree = ""; }; 124770000000000000000009 /* TerminalSurface+CloudImagePaste.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sources/TerminalSurface+CloudImagePaste.swift"; sourceTree = SOURCE_ROOT; }; 12477000000000000000000A /* TerminalSurface+ImageTransferTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sources/TerminalSurface+ImageTransferTarget.swift"; sourceTree = SOURCE_ROOT; }; D36A00090000000000000002 /* TerminalSurface+RendererRealizationSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/TerminalSurface+RendererRealizationSurface.swift"; sourceTree = ""; }; @@ -7378,6 +7405,7 @@ 9200A0019200A0019200A002 /* Workspace+RestoredWorkingDirectoryGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RestoredWorkingDirectoryGuard.swift"; sourceTree = ""; }; 52ACFA4197424088998A76C0 /* Workspace+RightSidebarToolDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RightSidebarToolDrop.swift"; sourceTree = ""; }; C548600A000000000000002 /* Workspace+SessionRestoreIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SessionRestoreIdentity.swift"; sourceTree = ""; }; + A36A5D72167E9864FC9EBD8D /* Workspace+SessionSnapshotProjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SessionSnapshotProjection.swift"; sourceTree = ""; }; C7268002000000000000002 /* Workspace+SidebarDirectories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SidebarDirectories.swift"; sourceTree = ""; }; C3744E100000000000000002 /* Workspace+SidebarStatusVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SidebarStatusVisibility.swift"; sourceTree = ""; }; C51A710000000000000000C2 /* Workspace+SimulatorPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SimulatorPane.swift"; sourceTree = ""; }; @@ -8902,6 +8930,7 @@ 11295000000000000000000F /* Workspace+ForkStartupInput.swift */, A4D77F29235F487C9276ED6A /* TerminalController+SurfaceResumeBindings.swift */, 6BAC6DCC5DA64C5FBA16A6AD /* DockSplitStore+AgentResumeOwnership.swift */, + 9855A0000000000000000006 /* TerminalStartupWorkingDirectoryPrefix+SavedOptions.swift */, 5F89ED4F392842F08A685C41 /* Workspace+AgentResumeOwnership.swift */, C3744E020000000000000001 /* Workspace+PanelLifecycle.swift */, 2DABDC3327C24142A1EF2518 /* Workspace+RemoteTerminalRetirement.swift */, @@ -10218,6 +10247,10 @@ C57B00050000000000000002 /* CmuxExtensionSidebarSelection+CustomSidebarName.swift */, 59065015952E1B5BE5E23519 /* Mobile */, 31B04B98376C9BA8B9E44DCB /* TerminalViewportUITestRecorder.swift */, + A36A5D72167E9864FC9EBD8D /* Workspace+SessionSnapshotProjection.swift */, + C082F12A68F721A58BCF82A4 /* ControlSurfaceResumeTarget+BindingSanitization.swift */, + 06EBC8AC18D6CB2E0C10CBA8 /* SurfaceResumeBindingSnapshot+WorkingDirectory.swift */, + B0585E5669F0C3945B3BAB02 /* SurfaceResumeBindingSnapshot+RestoreInput.swift */, D0C2DEE58D119E3588D1DEBE /* TerminalNotificationOrigin.swift */, 719B6457B507938674F1E684 /* NSView+WindowPointHitTest.swift */, B2EFC94293EC97FE78539D9E /* NotificationTextSanitizer.swift */, @@ -11387,6 +11420,15 @@ C1B1810000000000000015 /* MobileHostIrxSettingsMappingTests.swift */, A7C0F0010000000000000001 /* MacPairedMacBackupPublisherScopeTests.swift */, 5E2701030000000000000002 /* MacSentryStartupPolicyTests.swift */, + 8CE7878FAD5244615E0C8997 /* RemoteAgentRestoreWorkingDirectoryTests.swift */, + E857AD1B188E696019694C62 /* RemoteAgentRestoreWorkingDirectoryTests+Remote.swift */, + 27B72A450B64B509BE13A199 /* RemoteAgentRestoreWorkingDirectoryTests+Policy.swift */, + 3CDF7ACE675F78C970068F60 /* RemoteAgentRestoreWorkingDirectoryTests+Hibernation.swift */, + 9872CEFBD0DE96663DF2A372 /* RemoteResumeBindingRestorePolicyTests.swift */, + 0D74ABD05E11E6AF858BF0D5 /* RemoteResumeBindingLegacyPolicyTests.swift */, + A2B3DC562A0000B7F95DD2C5 /* SessionPersistenceResumeWorkingDirectoryTests.swift */, + 0EEC7989DC889A39ECE901C7 /* SessionPersistenceWorkingDirectoryPolicyTests.swift */, + C6B83CBA845BB1506D9C94AB /* SurfaceResumeAgentBindingGenerationTests+Continuation.swift */, D9077ACF715B2CE4B695A444 /* CloudMachineNotificationEventTests.swift */, 900759686B8F971DD0790E18 /* CloudMachineNotificationDeliveryTests.swift */, E107EF903AAF714086F52BC4 /* SurfaceSocketCommandTests.swift */, @@ -12923,6 +12965,7 @@ BFC914190FEF400683D6DEBC /* ControlSidebarAgentLifecycleRegistryScope.swift in Sources */, BE979B868F4D42199E5400E2 /* ControlSidebarPanelOwner.swift in Sources */, C51A73B30000000000000002 /* ControlSimulatorPendingTextInput.swift in Sources */, + 2430809C88169B7568E0325D /* ControlSurfaceResumeTarget+BindingSanitization.swift in Sources */, 11295000000000000000000E /* ControlSurfaceResumeTarget+ContinuationRecord.swift in Sources */, D8684000000000000000000F /* ControlSurfaceResumeTarget.swift in Sources */, 93300005A1B2C3D4E5F60718 /* ControlTerminalSocketBindingState.swift in Sources */, @@ -13321,7 +13364,7 @@ C1B1810000000000000009 /* MobileHostDiagnostics.swift in Sources */, B8B056D90000000000000001 /* MobileHostIdentity.swift in Sources */, A17070900000000000000002 /* MobileHostIrohApplicationLaneRouter.swift in Sources */, - C1B1810000000000000005 /* MobileHostIrohRuntime+Activation.swift in Sources */, + 225FCDA90B754A7CBAF13BF7 /* MobileHostIrohRuntime+Activation.swift in Sources */, C1B1810000000000000006 /* MobileHostIrohRuntime+Lifecycle.swift in Sources */, C1B1810000000000000007 /* MobileHostIrohRuntime.swift in Sources */, C1A070000000000000000004 /* MobileHostIrohServerEventWriter.swift in Sources */, @@ -13949,6 +13992,8 @@ F0ACC0DE000000000000000F /* SurfaceResumeBindingIndex.swift in Sources */, 8993B0018993B0018993B001 /* SurfaceResumeBindingSnapshot+ManagedSessionIdentity.swift in Sources */, 7989B0017989B0017989B001 /* SurfaceResumeBindingSnapshot+Remote.swift in Sources */, + 5F0DD396484DE282F679ED81 /* SurfaceResumeBindingSnapshot+RestoreInput.swift in Sources */, + 49DEF72DC12ED60862183BC0 /* SurfaceResumeBindingSnapshot+WorkingDirectory.swift in Sources */, F6572010A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+CodexUpdateCheck.swift in Sources */, 11295000000000000000000C /* SurfaceResumeCommandCanonicalizer+Fork.swift in Sources */, F6572000A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift in Sources */, @@ -14211,6 +14256,7 @@ 85510003A1B2C3D4E5F60001 /* TerminalShellResolver+CurrentUser.swift in Sources */, A5001543 /* TerminalSSHSessionDetector.swift in Sources */, D4A9923A0000000000000001 /* TerminalStartupRestoreCoordinator.swift in Sources */, + 9855A0000000000000000005 /* TerminalStartupWorkingDirectoryPrefix+SavedOptions.swift in Sources */, 124760000000000000000009 /* TerminalSurface+CloudImagePaste.swift in Sources */, 12476000000000000000000A /* TerminalSurface+ImageTransferTarget.swift in Sources */, D36A00090000000000000001 /* TerminalSurface+RendererRealizationSurface.swift in Sources */, @@ -14404,6 +14450,7 @@ 9200A0019200A0019200A001 /* Workspace+RestoredWorkingDirectoryGuard.swift in Sources */, 381B026EFFAB40449AF805E8 /* Workspace+RightSidebarToolDrop.swift in Sources */, C548600A000000000000001 /* Workspace+SessionRestoreIdentity.swift in Sources */, + EBD244628A0C0FE3E78C03C3 /* Workspace+SessionSnapshotProjection.swift in Sources */, C7268002000000000000001 /* Workspace+SidebarDirectories.swift in Sources */, C3744E100000000000000001 /* Workspace+SidebarStatusVisibility.swift in Sources */, C51A710000000000000000C1 /* Workspace+SimulatorPane.swift in Sources */, @@ -15008,7 +15055,7 @@ FE89827F4FAB9F7524A38B41 /* CLISendQueuedOutputTests.swift in Sources */, B900004CA1B2C3D4E5F60719 /* CLISocketPathResolver.swift in Sources */, A8D837B3AA85F4353C493DE1 /* CLISSHPTYAttachProbeReplyRegressionTests.swift in Sources */, - D0F102000000000000000001 /* CLISSHPTYAttachReplayBoundaryTests.swift in Sources */, + F1BD828B7BB144DD87F4D2D7 /* CLISSHPTYAttachReplayBoundaryTests.swift in Sources */, C73660020000000000000001 /* CLISSHPTYAttachSessionLostRespawnTests.swift in Sources */, C77070000000000000000004 /* CLISSHPTYAttachTransientBridgeFailureExitCodeTests.swift in Sources */, 6379A0016379A0016379A001 /* CLISSHPTYResizeInputTests.swift in Sources */, @@ -15424,12 +15471,18 @@ A64610020000000000000001 /* QuitConfirmationAlertPresenterTests.swift in Sources */, 966600019666000196660001 /* RecoverableMainWindowLifecycleTests.swift in Sources */, A6D1F0400000000000000002 /* ReleasingWindowControllerTests.swift in Sources */, + E3F39991CD8030E1EF9DB755 /* RemoteAgentRestoreWorkingDirectoryTests+Hibernation.swift in Sources */, + 66473DD776CF7BB08E750542 /* RemoteAgentRestoreWorkingDirectoryTests+Policy.swift in Sources */, + DF0D740B6BC7E8501EDAC910 /* RemoteAgentRestoreWorkingDirectoryTests+Remote.swift in Sources */, + 83F2D829F6B1191168A215E9 /* RemoteAgentRestoreWorkingDirectoryTests.swift in Sources */, 63A6792ED28244D28F5E9A83 /* RemoteClaudeTeamsRespawnRoutingTests.swift in Sources */, 791100030000000000000001 /* RemoteDisconnectLifecycleTests.swift in Sources */, 835000000000000000000006 /* RemoteInitialCommandBootstrapFailureTests.swift in Sources */, 835000000000000000000004 /* RemoteInitialCommandBootstrapTests.swift in Sources */, B11049000000000000000001 /* RemotePTYReconnectLifecycleTests.swift in Sources */, B11049000000000000000003 /* RemoteRelayTmuxCompatAuthorizationTests.swift in Sources */, + 2960A8C19B06F3ED0B05840A /* RemoteResumeBindingLegacyPolicyTests.swift in Sources */, + DCA0DF77A4592C7BA46E8DAE /* RemoteResumeBindingRestorePolicyTests.swift in Sources */, 7989A0017989A0017989A001 /* RemoteResumeBindingTests.swift in Sources */, REE0CA0000000000000000B2 /* RemotesClientTests.swift in Sources */, 806100020000000000000001 /* RemoteSessionCleanupLifecycleTests.swift in Sources */, @@ -15515,7 +15568,9 @@ 850000000000000000000002 /* SessionIndexTableViewportTests.swift in Sources */, 8A3392FE64E0605D942213D1 /* SessionIndexViewTests.swift in Sources */, F6572002A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift in Sources */, + AE3C5D0D17958A0195CABE9F /* SessionPersistenceResumeWorkingDirectoryTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + 441CD4F970F80399FA96EE4D /* SessionPersistenceWorkingDirectoryPolicyTests.swift in Sources */, 812600000000000000000003 /* SessionRemoteWorkspaceMoshRestoreTests.swift in Sources */, 806600000000000000000002 /* SessionRestorableAgentSnapshotPermissionModeTests.swift in Sources */, 077A3D2FFBE248DD88CF85BA /* SetAutoTitleSocketTests+CloudSidebar.swift in Sources */, @@ -15613,6 +15668,7 @@ D01100800000000000000004 /* SurfaceCatalogQueryServiceTests.swift in Sources */, 5873A6BE37C34CC1082864E2 /* SurfaceCatalogTests.swift in Sources */, 560A57B0605EC30E23A20456 /* SurfacePaneFactoryFocusTests.swift in Sources */, + 3BA4A4EBE25DCDE8749844FA /* SurfaceResumeAgentBindingGenerationTests+Continuation.swift in Sources */, 842300000000000000000007 /* SurfaceResumeAgentBindingGenerationTests.swift in Sources */, F6572032A1B2C3D4E5F60718 /* SurfaceResumeAgentHookDowngradeTests.swift in Sources */, F6572012A1B2C3D4E5F60718 /* SurfaceResumeBindingCodexUpdateCheckTests.swift in Sources */, diff --git a/cmuxTests/AgentSessionAutoResumeSwiftTests.swift b/cmuxTests/AgentSessionAutoResumeSwiftTests.swift index 367e08b37dc6..7ca62f46f12c 100644 --- a/cmuxTests/AgentSessionAutoResumeSwiftTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSwiftTests.swift @@ -1,8 +1,8 @@ -import Darwin -import Foundation import CMUXAgentLaunch import CmuxCore import CmuxSidebar +import Darwin +import Foundation import Testing #if canImport(cmux_DEV) diff --git a/cmuxTests/CMUXCLIForkVerbRegressionTests.swift b/cmuxTests/CMUXCLIForkVerbRegressionTests.swift index 913aa6f10ca5..0c05b9a38aac 100644 --- a/cmuxTests/CMUXCLIForkVerbRegressionTests.swift +++ b/cmuxTests/CMUXCLIForkVerbRegressionTests.swift @@ -112,6 +112,40 @@ struct CMUXCLIForkVerbRegressionTests { #expect(retargeted.preparedForkArguments() == ["ignore-cwd-agent", "--fork", "ignore-cwd-session"]) } + @Test + func retargetedForkWorkingDirectoryReplacesStaleRestoreSelection() throws { + let snapshot = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: "retargeted-fork-session", + workingDirectory: "/tmp/original", + launchCommand: AgentLaunchCommandSnapshot( + arguments: ["codex"], + workingDirectory: "/tmp/original" + ), + restoreWorkingDirectorySelection: .exact("/tmp/original") + ) + + let retargeted = snapshot.retargetingForkWorkingDirectory("/tmp/destination") + + #expect(retargeted.restoreWorkingDirectorySelection == .exact("/tmp/destination")) + #expect(retargeted.forkCommand(restoringWorkingDirectory: nil)?.contains("'/tmp/destination'") == true) + #expect(retargeted.forkCommand(restoringWorkingDirectory: nil)?.contains("'/tmp/original'") == false) + } + + @Test + func retargetingPreservesUnavailablePolicy() { + let snapshot = SessionRestorableAgentSnapshot( + kind: .codex, sessionId: "unavailable-retarget", + workingDirectory: "/tmp/captured", + restoreWorkingDirectorySelection: .unavailable + ) + let retargeted = snapshot.retargetingForkWorkingDirectory("/tmp/destination") + #expect(retargeted.restoreWorkingDirectorySelection == .unavailable) + #expect(retargeted.workingDirectory == nil) + #expect(retargeted.forkCommand(restoringWorkingDirectory: nil) == nil) + #expect(retargeted.preparedForkArguments() == nil) + } + @Test func surfaceResumeCanonicalizerUsesForkSelectorForLocalBindings() throws { let sessionID = "019dad34-d218-7943-b81a-eddac5c87951" diff --git a/cmuxTests/DeferredAgentResumeIndexFallbackTests.swift b/cmuxTests/DeferredAgentResumeIndexFallbackTests.swift index 9b89b645e4d3..cf2d947d7777 100644 --- a/cmuxTests/DeferredAgentResumeIndexFallbackTests.swift +++ b/cmuxTests/DeferredAgentResumeIndexFallbackTests.swift @@ -149,6 +149,162 @@ struct DeferredAgentResumeIndexFallbackTests { ) } + @Test("Deferred remote restore uses the matching custom registration") + func deferredRemoteRestoreUsesMatchingCustomRegistration() throws { + let defaultsName = "cmux-deferred-remote-registration-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: defaultsName)) + defer { defaults.removePersistentDomain(forName: defaultsName) } + + let workspace = Workspace(agentSessionAutoResumeDefaults: defaults) + defer { workspace.teardownAllPanels() } + let originalPanelID = try #require(workspace.focusedPanelId) + let paneID = try #require(workspace.paneId(forPanelId: originalPanelID)) + let workingDirectory = "/remote/project" + let sessionID = "custom-deferred-remote-session" + let persistentPTYSessionID = "custom-deferred-remote-pty" + let panel = try #require(workspace.newTerminalSurface( + inPane: paneID, + focus: true, + workingDirectory: workingDirectory, + runtimeSpawnPolicy: .heldForStartupRestoreAdmission, + remotePTYSessionID: persistentPTYSessionID, + suppressWorkspaceRemoteStartupCommand: true + )) + let context = SurfaceResumeRemoteContext( + workspaceID: workspace.id, + surfaceID: panel.id, + persistentPTYSessionID: persistentPTYSessionID + ) + let registration = CmuxVaultAgentRegistration( + id: "acme-agent", + name: "Acme Agent", + detect: CmuxVaultAgentDetectRule(processName: "custom-agent"), + sessionIdSource: .argvOption("--session"), + resumeCommand: "{{executable}} --session {{sessionId}}", + cwd: .preserve + ) + let launchCommand = AgentLaunchCommandSnapshot( + launcher: "custom-agent", + executablePath: "/usr/local/bin/custom-agent", + arguments: ["/usr/local/bin/custom-agent", "--session", sessionID], + workingDirectory: workingDirectory + ) + let binding = SurfaceResumeBindingSnapshot( + name: registration.name, + kind: registration.id, + command: "custom-agent --session \(sessionID)", + cwd: workingDirectory, + checkpointId: sessionID, + source: "agent-hook", + launchCommand: launchCommand, + restoreWorkingDirectorySelection: .exact(workingDirectory), + autoResume: true, + launchFlavor: .persistentSSH(context) + ) + let snapshot = SessionRestorableAgentSnapshot( + kind: .custom(registration.id), + sessionId: sessionID, + workingDirectory: workingDirectory, + launchCommand: launchCommand, + registration: registration, + restoreWorkingDirectorySelection: .exact(workingDirectory) + ) + workspace.surfaceResumeBindingsByPanelId[panel.id] = binding + workspace.terminalStartupRestoreCoordinator.stage( + panel: panel, + snapshot: snapshot, + resumeBinding: binding, + manualResumeAvailable: true, + willRunStartupCommand: false, + willRunStartupInput: false, + resumeWorkingDirectory: workingDirectory, + chatWorkingDirectory: workingDirectory, + defersStartupRestoreAdmission: true + ) + workspace.terminalStartupRestoreCoordinator.commitPendingRestores( + panelIDs: [panel.id] + ) + workspace.deferredAgentResumeRestoresByPanelId[panel.id] = DeferredAgentResumeRestore( + stablePanelID: panel.id, + restorableAgent: nil, + resumeBinding: binding, + restoresRemoteWorkspaceTerminalSnapshot: true, + remoteResumeContext: context, + workingDirectory: workingDirectory, + resumeWorkingDirectory: workingDirectory + ) + + workspace.resolveDeferredAgentResumeRestoresForTesting(using: .empty) + + let startupInput = try #require(workspace.restoredAgentLifecycle.startupInput(panelId: panel.id)) + #expect(startupInput == binding.remoteStartupInput(registration: registration)) + #expect(startupInput.contains("custom-agent")) + #expect(startupInput.contains(sessionID)) + #expect(workspace.deferredAgentResumeRestoresByPanelId[panel.id] == nil) + #expect(workspace.surfaceResumeBindingsByPanelId[panel.id] != nil) + #expect(!panel.surface.admitStartupRestoreRuntime(initialInput: "unexpected\n")) + } + + @Test("Deferred remote restore cancels a binding without an exact cwd policy") + func deferredRemoteRestoreFailsClosedWithoutExactWorkingDirectory() throws { + let store = DockSplitStore(workspaceId: UUID(), baseDirectoryProvider: { nil }) + defer { store.closeAllPanels() } + let panel = TerminalPanel(workspaceId: store.workspaceId) + store.panels[panel.id] = panel + let workingDirectory = "/remote/project" + let sessionID = "recorded-fallback-deferred-remote-session" + let persistentPTYSessionID = "recorded-fallback-deferred-remote-pty" + let context = SurfaceResumeRemoteContext( + workspaceID: store.workspaceId, + surfaceID: panel.id, + persistentPTYSessionID: persistentPTYSessionID + ) + let binding = SurfaceResumeBindingSnapshot( + kind: "codex", + command: "codex resume \(sessionID)", + cwd: workingDirectory, + checkpointId: sessionID, + source: "agent-hook", + restoreWorkingDirectorySelection: .recordedFallback( + preferred: workingDirectory + ), + autoResume: true, + launchFlavor: .persistentSSH(context) + ) + store.surfaceResumeBindingsByPanelId[panel.id] = binding + store.terminalStartupRestoreCoordinator.stage( + panel: panel, + snapshot: nil, + resumeBinding: binding, + manualResumeAvailable: true, + willRunStartupCommand: false, + willRunStartupInput: false, + resumeWorkingDirectory: workingDirectory, + chatWorkingDirectory: workingDirectory, + defersStartupRestoreAdmission: true + ) + store.terminalStartupRestoreCoordinator.commitPendingRestores( + panelIDs: [panel.id] + ) + store.deferredAgentResumeRestoresByPanelId[panel.id] = DeferredAgentResumeRestore( + stablePanelID: panel.id, + restorableAgent: nil, + resumeBinding: binding, + restoresRemoteWorkspaceTerminalSnapshot: true, + remoteResumeContext: context, + workingDirectory: workingDirectory, + resumeWorkingDirectory: workingDirectory + ) + + store.resolveDeferredAgentResumeRestoresForTesting(using: .empty) + + #expect(store.deferredAgentResumeRestoresByPanelId[panel.id] == nil) + #expect(store.restoredAgentLifecycle.startupInput(panelId: panel.id) == nil) + #expect(store.restoredAgentLifecycle.resumeStatesByPanelId[panel.id] == .manualResumeAvailable) + #expect(store.surfaceResumeBindingsByPanelId[panel.id]?.autoResume == false) + #expect(panel.surface.admitStartupRestoreRuntime(initialInput: "unexpected\n")) + } + @Test("A positive ownership decision still retires the binding") func ownershipCancelRetiresBinding() throws { let workspace = Workspace() diff --git a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift index 4280cab9a904..49a68d84a815 100644 --- a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift +++ b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift @@ -244,6 +244,7 @@ struct GhosttyTerminalViewVisibilityPolicyTests { } @Test func detachedCurrentHostPersistsHiddenVisibilityBeforeRebind() async { + let testWorkspace = TerminalPortalTestWorkspace() let size = NSSize(width: 480, height: 320) let window = NSWindow( contentRect: NSRect(origin: .zero, size: size), @@ -257,7 +258,7 @@ struct GhosttyTerminalViewVisibilityPolicyTests { window.contentView = container container.addSubview(host) - let panel = TerminalPanel(workspaceId: UUID()) + let panel = TerminalPanel(workspaceId: testWorkspace.id) let coordinator = GhosttyTerminalView.Coordinator() var ownsPane = true coordinator.attachGeneration = 1 @@ -286,6 +287,7 @@ struct GhosttyTerminalViewVisibilityPolicyTests { TerminalWindowPortalRegistry.detach(hostedView: panel.hostedView) window.close() panel.surface.teardownSurface() + testWorkspace.tearDown() } window.orderFront(nil) @@ -324,9 +326,7 @@ struct GhosttyTerminalViewVisibilityPolicyTests { "A reconciliation that no longer owns the portal must still project the latest ring state" ) - // Reattach before the queued geometry pass can prune the detached, - // hidden entry. Synchronizing now distinguishes persisted visibility - // intent from the geometry pass merely hiding or removing the view. + // Reattach before queued pruning; synchronization distinguishes persisted visibility from geometry hiding. container.addSubview(host) #expect(TerminalWindowPortalRegistry.isHostedView(panel.hostedView, boundTo: host)) TerminalWindowPortalRegistry.synchronizeForAnchor(host, syncLayout: false) @@ -338,6 +338,7 @@ struct GhosttyTerminalViewVisibilityPolicyTests { } @Test func portalRegistryBindsDeferWindowLayoutUntilCoalescedPass() async { + let testWorkspace = TerminalPortalTestWorkspace() let size = NSSize(width: 640, height: 360) let window = NSWindow( contentRect: NSRect(origin: .zero, size: size), @@ -353,14 +354,15 @@ struct GhosttyTerminalViewVisibilityPolicyTests { container.addSubview(firstAnchor) container.addSubview(secondAnchor) - let firstPanel = TerminalPanel(workspaceId: UUID()) - let secondPanel = TerminalPanel(workspaceId: UUID()) + let firstPanel = TerminalPanel(workspaceId: testWorkspace.id) + let secondPanel = TerminalPanel(workspaceId: testWorkspace.id) defer { TerminalWindowPortalRegistry.detach(hostedView: firstPanel.hostedView) TerminalWindowPortalRegistry.detach(hostedView: secondPanel.hostedView) window.close() firstPanel.surface.teardownSurface() secondPanel.surface.teardownSurface() + testWorkspace.tearDown() } window.orderFront(nil) @@ -408,6 +410,7 @@ struct GhosttyTerminalViewVisibilityPolicyTests { } @Test func workspaceRevealKeepsTerminalSizeUntilAnUnchangedGeometryPass() async throws { + let testWorkspace = TerminalPortalTestWorkspace() let size = NSSize(width: 480, height: 320) let window = NSWindow( contentRect: NSRect(origin: .zero, size: size), @@ -420,11 +423,12 @@ struct GhosttyTerminalViewVisibilityPolicyTests { let anchor = NSView(frame: container.bounds) window.contentView = container container.addSubview(anchor) - let panel = TerminalPanel(workspaceId: UUID()) + let panel = TerminalPanel(workspaceId: testWorkspace.id) defer { TerminalWindowPortalRegistry.detach(hostedView: panel.hostedView) window.close() panel.surface.teardownSurface() + testWorkspace.tearDown() } window.orderFront(nil) @@ -464,9 +468,7 @@ struct GhosttyTerminalViewVisibilityPolicyTests { container.needsLayout = true container.resetLayoutCount() - // Deliver the same external geometry pass used by workspace reveal - // synchronously, before its queued follow-up. The override only selects - // notification delivery; the native surface is not in a live resize. + // Deliver the workspace-reveal geometry pass synchronously before its queued follow-up; native surface is not resizing. portal.isWindowLiveResizeActiveOverrideForTesting = true NotificationCenter.default.post(name: NSWindow.didResizeNotification, object: window) portal.isWindowLiveResizeActiveOverrideForTesting = false @@ -477,8 +479,7 @@ struct GhosttyTerminalViewVisibilityPolicyTests { "The pass that changes layout must not publish an intermediate terminal size" ) - // The next layout restores the workspace's original pane geometry. - // There is no reason to resize its native surface or notify its PTY. + // The next layout restores the original pane geometry without resizing the native surface or notifying its PTY. anchor.frame.size = size TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window, forceImmediate: false) await flushPortalReconciliationPasses() @@ -489,8 +490,7 @@ struct GhosttyTerminalViewVisibilityPolicyTests { anchor.frame.size.width = 360 TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window, forceImmediate: false) await flushPortalReconciliationPasses() - // Native size publication also waits for AppKit's display/layout - // turn. Main-queue barriers alone do not drive that turn in an async test. + // Native size publication waits for AppKit's display/layout turn; main-queue barriers alone do not drive it. let clock = ContinuousClock() let deadline = clock.now.advanced(by: .seconds(1)) while (try terminalSize()).width >= initialTerminalSize.width, diff --git a/cmuxTests/KimiResumeReviewRegressionTests.swift b/cmuxTests/KimiResumeReviewRegressionTests.swift index 4f8d35e2ad7b..c156e6c9b6c2 100644 --- a/cmuxTests/KimiResumeReviewRegressionTests.swift +++ b/cmuxTests/KimiResumeReviewRegressionTests.swift @@ -10,85 +10,8 @@ import Testing @Suite("Kimi resume review regressions") struct KimiResumeReviewRegressionTests { - @Test("Bundled Kimi wrapper captures launch metadata before exec") - func bundledWrapperCapturesLaunchMetadata() throws { - let fileManager = FileManager.default - let bundledCLIURL = try BundledCLITestSupport.bundledCLIURL( - for: CLINotifyProcessIntegrationRegressionTests.self - ) - let wrapperURL = bundledCLIURL - .deletingLastPathComponent() - .appendingPathComponent("kimi", isDirectory: false) - let executableWrapperURL = try #require( - fileManager.isExecutableFile(atPath: wrapperURL.path) ? wrapperURL : nil - ) - - let root = fileManager.temporaryDirectory - .appendingPathComponent("cmux-kimi-wrapper-\(UUID().uuidString)", isDirectory: true) - let realBinURL = root.appendingPathComponent("real-bin", isDirectory: true) - let launchDirectoryURL = root.appendingPathComponent("launch-repo", isDirectory: true) - let captureURL = root.appendingPathComponent("capture.txt", isDirectory: false) - let configURL = root.appendingPathComponent("kimi.toml", isDirectory: false) - try fileManager.createDirectory(at: realBinURL, withIntermediateDirectories: true) - try fileManager.createDirectory(at: launchDirectoryURL, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: root) } - - let realKimiURL = realBinURL.appendingPathComponent("kimi", isDirectory: false) - try """ - #!/bin/sh - { - printf 'kind=%s\n' "${CMUX_AGENT_LAUNCH_KIND-}" - printf 'executable=%s\n' "${CMUX_AGENT_LAUNCH_EXECUTABLE-}" - printf 'argv=%s\n' "${CMUX_AGENT_LAUNCH_ARGV_B64-}" - printf 'cwd=%s\n' "${CMUX_AGENT_LAUNCH_CWD-}" - } > "$CMUX_KIMI_TEST_CAPTURE" - """.write(to: realKimiURL, atomically: true, encoding: .utf8) - try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: realKimiURL.path) - - let process = Process() - process.executableURL = executableWrapperURL - process.arguments = [ - "--model", "kimi-k2", - "--config-file", configURL.path, - ] - process.currentDirectoryURL = launchDirectoryURL - process.environment = [ - "HOME": root.path, - "PATH": "\(realBinURL.path):/usr/bin:/bin", - "PWD": launchDirectoryURL.path, - "CMUX_SURFACE_ID": UUID().uuidString, - "CMUX_KIMI_TEST_CAPTURE": captureURL.path, - ] - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - try process.run() - process.waitUntilExit() - #expect(process.terminationStatus == 0) - - let capture = try String(contentsOf: captureURL, encoding: .utf8) - let fields: [String: String] = Dictionary(uniqueKeysWithValues: capture.split(separator: "\n").compactMap { line in - let parts = line.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) - guard parts.count == 2 else { return nil } - return (String(parts[0]), String(parts[1])) - }) - #expect(fields["kind"] == "kimi") - #expect(fields["executable"] == realKimiURL.path) - #expect(fields["cwd"] == launchDirectoryURL.path) - - let encodedArgv = try #require(fields["argv"]) - let argvData = try #require(Data(base64Encoded: encodedArgv)) - let capturedArgv = argvData - .split(separator: 0) - .map { String(decoding: $0, as: UTF8.self) } - #expect(capturedArgv == [ - realKimiURL.path, - "--model", "kimi-k2", - "--config-file", configURL.path, - ]) - } - - @Test("Value-identical user Kimi registration keeps runtime cwd ownership") - func valueIdenticalCustomRegistrationKeepsRuntimeDirectory() throws { + @Test("User Kimi registration keeps runtime cwd ownership") + func customRegistrationKeepsRuntimeDirectory() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cmux-custom-kimi-equal-\(UUID().uuidString)", isDirectory: true) @@ -100,9 +23,12 @@ struct KimiResumeReviewRegressionTests { try fileManager.createDirectory(at: stateDirectory, withIntermediateDirectories: true) defer { try? fileManager.removeItem(at: root) } - let userRegistration = try JSONDecoder().decode( - CmuxVaultAgentRegistration.self, - from: JSONEncoder().encode(CmuxVaultAgentRegistration.builtInKimi) + let userRegistration = CmuxVaultAgentRegistration( + id: "kimi", + name: "Custom Kimi", + detect: CmuxVaultAgentDetectRule(processName: "custom-kimi"), + sessionIdSource: .argvOption("--resume"), + resumeCommand: "custom-kimi --resume {{sessionId}}" ) let registry = CmuxVaultAgentRegistry(registrations: [ .builtInKimi, @@ -214,7 +140,10 @@ struct KimiResumeReviewRegressionTests { #expect(persisted.panels.first?.terminal?.agent?.kind == .custom("kimi")) #expect(persisted.panels.first?.terminal?.resumeBinding?.kind == "kimi") - let restored = Workspace(agentSessionAutoResumeDefaults: defaults) + let restored = Workspace( + agentSessionAutoResumeDefaults: defaults, + restorableAgentIndexProvider: { .empty } + ) restored.restoreSessionSnapshot(persisted) let restoredPanelID = try #require(restored.focusedPanelId) let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelID)) @@ -229,6 +158,262 @@ struct KimiResumeReviewRegressionTests { #expect(launcher.contains("'custom-kimi' '--resume' '\(sessionID)'"), "\(launcher)") #expect(!launcher.contains("'kimi' '--resume' '\(sessionID)'"), "\(launcher)") } + + @Test("Custom Kimi registrations preserve profile -w options during exact restore") + func customKimiExactRestorePreservesProfileWorkingDirectoryOption() throws { + let workingDirectory = "/remote/project" + let profile = "profile-a" + let registration = CmuxVaultAgentRegistration( + id: "kimi", + name: "Custom Kimi", + detect: CmuxVaultAgentDetectRule(processName: "custom-kimi"), + sessionIdSource: .argvOption("--resume"), + resumeCommand: "custom-kimi --resume {{sessionId}}" + ) + let launchCommand = AgentLaunchCommandSnapshot( + launcher: "kimi", + executablePath: "/Users/example/.local/bin/custom-kimi", + arguments: [ + "/Users/example/.local/bin/custom-kimi", + "-w", profile, + "--model", "custom-model", + ], + workingDirectory: "/Users/example/local-project", + capturedAt: 1_750_000_000, + source: "test" + ) + let snapshot = SessionRestorableAgentSnapshot( + kind: .custom("kimi"), + sessionId: "custom-kimi-session", + workingDirectory: workingDirectory, + launchCommand: launchCommand, + registration: registration, + restoreWorkingDirectorySelection: .exact(workingDirectory) + ) + + let constrained = try #require( + snapshot.constrainedLaunchCommand( + launchCommand, + selection: .exact(workingDirectory) + ) + ) + #expect(constrained.arguments == launchCommand.arguments) + #expect(constrained.arguments.dropFirst().contains("-w")) + #expect(constrained.arguments.contains(profile)) + } + + @MainActor + @Test("Binding-only custom Kimi restore preserves profile working-directory options") + func bindingOnlyCustomKimiRestorePreservesProfileOption() throws { + let manager = TabManager(autoWelcomeIfNeeded: false) + defer { manager.tabs.forEach { $0.teardownAllPanels() } } + let workspace = try #require(manager.selectedWorkspace) + let panelID = try #require(workspace.focusedPanelId) + let target = ControlSurfaceResumeTarget.workspace( + tabManager: manager, + workspace: workspace, + surfaceID: panelID + ) + let sessionID = "binding-only-custom-kimi-session" + let profile = "profile-a" + let launchCommand = AgentLaunchCommandSnapshot( + launcher: "custom-kimi", + executablePath: "custom-kimi", + arguments: ["custom-kimi", "--resume", sessionID, "-w", profile] + ) + let binding = SurfaceResumeBindingSnapshot( + kind: "kimi", + command: "custom-kimi --resume \(sessionID) -w \(profile)", + checkpointId: sessionID, + source: "agent-hook", + launchCommand: launchCommand, + restoreWorkingDirectorySelection: .exact("/remote/project") + ) + + let record = try #require( + TerminalController.shared.controlSurfaceBindingContinuationRecord( + target: target, + binding: binding, + compatibilityBinding: nil, + restoredAgentExists: false + ) + ) + #expect(record.launchCommand?.arguments == launchCommand.arguments) + #expect(record.launchCommand?.arguments.contains("-w") == true) + #expect(record.launchCommand?.arguments.contains(profile) == true) + } + + @MainActor + @Test("Binding continuation reuses sanitized launch data for prepared resume and fork argv") + func bindingContinuationSanitizesPreparedArguments() throws { + let manager = TabManager(autoWelcomeIfNeeded: false) + defer { manager.tabs.forEach { $0.teardownAllPanels() } } + let workspace = try #require(manager.selectedWorkspace) + let panelID = try #require(workspace.focusedPanelId) + let target = ControlSurfaceResumeTarget.workspace( + tabManager: manager, + workspace: workspace, + surfaceID: panelID + ) + let sessionID = "binding-continuation-codex-session" + let capturedDirectory = "/Users/example/local-codex-project" + let trustedDirectory = "/remote/codex-project" + let launchCommand = AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "codex", + arguments: ["codex", "resume", sessionID, "-C", capturedDirectory, "--model", "test-model"], + workingDirectory: capturedDirectory + ) + workspace.setRestoredAgentSnapshotForTesting( + SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionID, + workingDirectory: capturedDirectory, + launchCommand: launchCommand + ), + panelId: panelID + ) + let binding = SurfaceResumeBindingSnapshot( + kind: "codex", + command: "codex resume \(sessionID) -C '\(capturedDirectory)'", + checkpointId: sessionID, + source: "agent-hook", + launchCommand: launchCommand, + restoreWorkingDirectorySelection: .exact(trustedDirectory) + ) + + let record = try #require( + TerminalController.shared.controlSurfaceBindingContinuationRecord( + target: target, + binding: binding, + compatibilityBinding: nil, + restoredAgentExists: true + ) + ) + let launchArguments = try #require(record.launchCommand?.arguments) + let preparedArguments = try #require(record.preparedArguments) + let forkArguments = try #require(record.forkArguments) + #expect(!launchArguments.contains(capturedDirectory)) + #expect(!preparedArguments.contains(capturedDirectory)) + #expect(!forkArguments.contains(capturedDirectory)) + #expect(preparedArguments.contains(sessionID)) + #expect(forkArguments.contains(sessionID)) + } + + @MainActor + @Test("Control restore strips native Kimi cwd flags without consuming custom profiles") + func controlRestoreUsesOnlyMatchingNativeKimiPolicy() throws { + let manager = TabManager(autoWelcomeIfNeeded: false) + defer { manager.tabs.forEach { $0.teardownAllPanels() } } + let workspace = try #require(manager.selectedWorkspace) + let panelID = try #require(workspace.focusedPanelId) + let target = ControlSurfaceResumeTarget.workspace( + tabManager: manager, + workspace: workspace, + surfaceID: panelID + ) + + let nativeSessionID = "native-kimi-session" + let capturedDirectory = "/Users/example/local-kimi-project" + let trustedDirectory = "/remote/kimi-project" + let nativeLaunch = AgentLaunchCommandSnapshot( + launcher: "kimi", + executablePath: "kimi", + arguments: ["kimi", "--resume", nativeSessionID, "-w", capturedDirectory], + workingDirectory: capturedDirectory + ) + workspace.setRestoredAgentSnapshotForTesting( + SessionRestorableAgentSnapshot( + kind: .kimi, + sessionId: nativeSessionID, + workingDirectory: capturedDirectory, + launchCommand: nativeLaunch, + registration: .builtInKimi + ), + panelId: panelID + ) + let nativeBinding = SurfaceResumeBindingSnapshot( + kind: "kimi", + command: "kimi --resume \(nativeSessionID) -w '\(capturedDirectory)'", + cwd: capturedDirectory, + checkpointId: nativeSessionID, + source: "manual", + launchCommand: nativeLaunch, + restoreWorkingDirectorySelection: .exact(trustedDirectory) + ) + + let nativeRecord = try #require( + TerminalController.shared.controlSurfaceRestoreRecord( + target: target, + binding: nativeBinding + ) + ) + let nativeArguments = try #require(nativeRecord.launchCommand?.arguments) + #expect(!nativeArguments.contains("-w")) + #expect(!nativeArguments.contains(capturedDirectory)) + #expect(nativeRecord.preparedArguments == nativeArguments) + + let profile = "profile-a" + let customSessionID = "custom-kimi-profile-session" + let customRegistration = CmuxVaultAgentRegistration( + id: "kimi", + name: "Custom Kimi", + detect: CmuxVaultAgentDetectRule(processName: "custom-kimi"), + sessionIdSource: .argvOption("--resume"), + resumeCommand: "custom-kimi --resume {{sessionId}}" + ) + let customLaunch = AgentLaunchCommandSnapshot( + launcher: "kimi", + executablePath: "custom-kimi", + arguments: ["custom-kimi", "--resume", customSessionID, "-w", profile] + ) + let customBinding = SurfaceResumeBindingSnapshot( + kind: "kimi", + command: "custom-kimi --resume \(customSessionID) -w \(profile)", + checkpointId: customSessionID, + source: "manual", + launchCommand: customLaunch, + restoreWorkingDirectorySelection: .exact(trustedDirectory) + ) + workspace.setRestoredAgentSnapshotForTesting( + SessionRestorableAgentSnapshot( + kind: .custom("kimi"), + sessionId: customSessionID, + workingDirectory: trustedDirectory, + launchCommand: customLaunch, + registration: customRegistration + ), + panelId: panelID + ) + + let customRecord = try #require( + TerminalController.shared.controlSurfaceRestoreRecord( + target: target, + binding: customBinding + ) + ) + #expect(customRecord.launchCommand?.arguments == customLaunch.arguments) + + // A native snapshot from another conversation is stale evidence and + // must not reinterpret the binding's custom profile as a cwd. + workspace.setRestoredAgentSnapshotForTesting( + SessionRestorableAgentSnapshot( + kind: .kimi, + sessionId: nativeSessionID, + workingDirectory: capturedDirectory, + launchCommand: nativeLaunch, + registration: .builtInKimi + ), + panelId: panelID + ) + let staleSnapshotRecord = try #require( + TerminalController.shared.controlSurfaceRestoreRecord( + target: target, + binding: customBinding + ) + ) + #expect(staleSnapshotRecord.launchCommand?.arguments == customLaunch.arguments) + } } extension CLINotifyProcessIntegrationRegressionTests { diff --git a/cmuxTests/MachinesPanelModelTests.swift b/cmuxTests/MachinesPanelModelTests.swift index b810e873958d..9e225362695c 100644 --- a/cmuxTests/MachinesPanelModelTests.swift +++ b/cmuxTests/MachinesPanelModelTests.swift @@ -757,14 +757,14 @@ final class MachinesPanelModelTests: XCTestCase { } @MainActor - func testCatalogWorkspaceGroupUsesLegacyWorkspaceWhenRemoteViewsAreEmpty() throws { + func testCatalogWorkspaceGroupUsesLegacyWorkspaceWhenRemoteViewsAreMissing() throws { let machine = SurfaceMachineID.cloud("legacy-group-test") let workspace = SurfaceRemoteWorkspace(id: "ws_legacy", name: "legacy", index: 0, focused: true) var resource = terminal(machine, "term_legacy", title: "shell") - // Older snapshots can include the explicit zero-view marker and still - // retain the single-workspace compatibility field. + // Legacy snapshots omit view metadata; an explicit empty list instead + // marks a detached resource and must not revive stale membership. resource.remoteWorkspace = workspace - resource.remoteViews = [] + resource.remoteViews = nil let catalog = SurfaceCatalog() // The catalog drops writes for a cloud machine with no registered provider. let provider = GroupFakeProvider(machine: machine) diff --git a/cmuxTests/MobileHostConnectionEventLaneTests.swift b/cmuxTests/MobileHostConnectionEventLaneTests.swift index 1684310f2f54..a5cd7e11aac3 100644 --- a/cmuxTests/MobileHostConnectionEventLaneTests.swift +++ b/cmuxTests/MobileHostConnectionEventLaneTests.swift @@ -57,7 +57,6 @@ extension MobileHostAuthorizationTests { Data(#"{"id":"status","method":"mobile.host.status","params":{}}"#.utf8) ) await session.debugHandleReceiveDataForTesting(frame) - try await Task.sleep(nanoseconds: 25_000_000) #expect(await recorder.recordedIDs().isEmpty) await session.close(reason: "test cleanup") } @@ -86,7 +85,6 @@ extension MobileHostAuthorizationTests { let subscribedCloseIDs = await recorder.recordedIDs() #expect(subscribedCloseIDs.isEmpty) _ = await session.unsubscribe(streamID: "events") - try await Task.sleep(nanoseconds: 25_000_000) #expect(await recorder.recordedIDs().isEmpty) await session.close(reason: "test cleanup") } @@ -453,9 +451,8 @@ extension MobileHostAuthorizationTests { payload: ["surface_id": "surface-stall-8842", "full": true] ) await transport.waitUntilSendStalled() - // Exercise an unresolved send across suspension before verifying - // that the connection has not been closed on the application's behalf. - try await Task.sleep(for: .milliseconds(30)) + // The send-stall gate is the causal synchronization point; no idle + // timeout exists that needs to elapse before checking the connection. #expect(await recorder.recordedIDs().isEmpty) #expect(await transport.observedCloseCount() == 0) await session.close(reason: "test complete") diff --git a/cmuxTests/MobileHostConnectionLifecycleTests.swift b/cmuxTests/MobileHostConnectionLifecycleTests.swift index b1879cd868c6..05918c52ee3b 100644 --- a/cmuxTests/MobileHostConnectionLifecycleTests.swift +++ b/cmuxTests/MobileHostConnectionLifecycleTests.swift @@ -553,8 +553,6 @@ extension MobileHostAuthorizationTests { Data(#"{"id":"subscribe","method":"mobile.events.subscribe","params":{"stream_id":"events","topics":["terminal.updated"]}}"#.utf8) ) await session.debugHandleReceiveDataForTesting(frame) - try await Task.sleep(nanoseconds: 25_000_000) - try await Task.sleep(nanoseconds: 25_000_000) #expect(await recorder.recordedIDs().isEmpty) await session.close(reason: "test cleanup") } diff --git a/cmuxTests/NotificationRowSnapshotBoundaryTests.swift b/cmuxTests/NotificationRowSnapshotBoundaryTests.swift index cd6d09aa9d4e..07598f569b75 100644 --- a/cmuxTests/NotificationRowSnapshotBoundaryTests.swift +++ b/cmuxTests/NotificationRowSnapshotBoundaryTests.swift @@ -72,24 +72,24 @@ struct NotificationRowSnapshotBoundaryTests { ) } - @Test func workspaceTitleIndexUsesRenamedGroupName() throws { + @Test func workspaceTitleIndexUsesRenamedGroupInsteadOfUserOwnedAnchorTitle() throws { let manager = TabManager(autoWelcomeIfNeeded: false) manager.addWorkspace(autoWelcomeIfNeeded: false) let childId = try #require(manager.tabs.first?.id) let groupId = try #require( manager.createWorkspaceGroup(name: "Original Group", childWorkspaceIds: [childId]) ) + manager.setWorkspaceGroupAnchor(groupId: groupId, workspaceId: childId) let group = try #require(manager.workspaceGroups.first { $0.id == groupId }) let anchor = try #require(manager.tabs.first { $0.id == group.anchorWorkspaceId }) - let staleAnchorTitle = anchor.title - + let userOwnedAnchorTitle = anchor.title let appDelegate = AppDelegate() let windowId = appDelegate.registerMainWindowContextForTesting(tabManager: manager) defer { appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) } manager.renameWorkspaceGroup(groupId: groupId, name: "Renamed Group") - #expect(anchor.title == staleAnchorTitle) + #expect(anchor.title == userOwnedAnchorTitle) #expect(appDelegate.tabTitlesByTabId()[anchor.id] == "Renamed Group") } diff --git a/cmuxTests/RemoteAgentRestoreWorkingDirectoryTests+Hibernation.swift b/cmuxTests/RemoteAgentRestoreWorkingDirectoryTests+Hibernation.swift new file mode 100644 index 000000000000..90df6e1bf5e8 --- /dev/null +++ b/cmuxTests/RemoteAgentRestoreWorkingDirectoryTests+Hibernation.swift @@ -0,0 +1,423 @@ +import AppKit +import CMUXAgentLaunch +import CmuxControlSocket +import CmuxCore +import CmuxSidebar +import Darwin +import Foundation +import Testing +@testable import CmuxTerminal + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif +extension RemoteAgentRestoreWorkingDirectoryTests { + @Test func exactRemoteRebuildSupportsRegistryOwnedKindWithoutSnapshot() throws { + let sessionID = "snapshotless-grok-session" + let trustedDirectory = "/srv/remote-grok" + let binding = SurfaceResumeBindingSnapshot( + kind: "grok", + command: "grok -r \(sessionID)", + cwd: trustedDirectory, + checkpointId: sessionID, + source: "agent-hook", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "grok", + executablePath: "/usr/local/bin/grok", + arguments: ["/usr/local/bin/grok"], + workingDirectory: trustedDirectory + ), + restoreWorkingDirectorySelection: .exact(trustedDirectory), + autoResume: true + ) + + let input = try #require(binding.remoteStartupInput()) + + #expect(input.contains("grok"), Comment(rawValue: input)) + #expect(input.contains(sessionID), Comment(rawValue: input)) + #expect(input.contains(trustedDirectory), Comment(rawValue: input)) + } + + @MainActor + @Test(arguments: [RestorableAgentKind.codex, .opencode]) + func remoteManualResumeRecordUsesOnlyTrustedReportedDirectory( + kind: RestorableAgentKind + ) throws { + let localWorkspaceDirectory = "/Users/alice/development" + let capturedAgentDirectory = "/Users/alice/captured-agent-cwd" + let capturedLaunchDirectory = "/Users/alice/captured-launch-cwd" + let capturedArgumentDirectory = "/Users/alice/captured-argument-cwd" + let trustedRemoteDirectory = "/home/remote/current-project" + let remoteCommand = "ssh cmux-remote" + let sessionId = "\(kind.rawValue)-remote-manual-\(UUID().uuidString)" + let executable = kind == .codex ? "codex" : "opencode" + let cwdOption = kind == .codex ? "-C" : "--cwd" + let source = Workspace( + workingDirectory: localWorkspaceDirectory, + initialTerminalCommand: remoteCommand + ) + defer { source.teardownAllPanels() } + let sourcePanelId = try #require(source.focusedPanelId) + source.configureRemoteConnection( + remoteWorkspaceConfiguration(command: remoteCommand), + autoConnect: false + ) + #expect(source.updateRemotePanelDirectory( + panelId: sourcePanelId, + directory: trustedRemoteDirectory + )) + source.updatePanelShellActivityState(panelId: sourcePanelId, state: .commandRunning) + source.setRestoredAgentSnapshotForTesting( + SessionRestorableAgentSnapshot( + kind: kind, + sessionId: sessionId, + workingDirectory: capturedAgentDirectory, + launchCommand: AgentLaunchCommandSnapshot( + launcher: executable, + executablePath: "/usr/local/bin/\(executable)", + arguments: [ + "/usr/local/bin/\(executable)", + cwdOption, + capturedArgumentDirectory, + "--model", + "test-model", + ], + workingDirectory: capturedLaunchDirectory, + environment: [:], + capturedAt: 1_777_777_777, + source: "process" + ) + ), + panelId: sourcePanelId + ) + #expect(source.setSurfaceResumeBinding( + SurfaceResumeBindingSnapshot( + kind: kind.rawValue, + command: "\(executable) resume \(sessionId) \(cwdOption) '\(capturedArgumentDirectory)'", + cwd: capturedAgentDirectory, + checkpointId: sessionId, + source: "agent-hook", + launchCommand: AgentLaunchCommandSnapshot( + launcher: executable, + executablePath: "/usr/local/bin/\(executable)", + arguments: [ + "/usr/local/bin/\(executable)", + cwdOption, + capturedArgumentDirectory, + "--model", + "test-model", + ], + workingDirectory: capturedLaunchDirectory + ), + autoResume: true + ), + panelId: sourcePanelId + )) + + let snapshot = source.sessionSnapshot(includeScrollback: false) + try withRestoredRemoteSurface( + snapshot, + sourcePanelId: sourcePanelId, + autoResumeAgentSessions: false + ) { workspace, panelId, panel, restoreRecord in + #expect(panel.surface.initialInput == nil) + #expect(workspace.restoredAgentResumeStatesByPanelId[panelId] == .manualResumeAvailable) + #expect(restoreRecord.kind == kind.rawValue) + #expect(restoreRecord.checkpointID == sessionId) + #expect(restoreRecord.workingDirectory == trustedRemoteDirectory) + #expect(restoreRecord.preparedArgumentsWorkingDirectory == trustedRemoteDirectory) + + let launchCommand = try #require(restoreRecord.launchCommand) + #expect(launchCommand.workingDirectory == nil) + let launchArguments = launchCommand.arguments.joined(separator: " ") + #expect(!launchArguments.contains(capturedAgentDirectory)) + #expect(!launchArguments.contains(capturedLaunchDirectory)) + #expect(!launchArguments.contains(capturedArgumentDirectory)) + #expect(!launchCommand.arguments.contains(cwdOption)) + + let preparedArguments = try #require(restoreRecord.preparedArguments) + .joined(separator: " ") + #expect(!preparedArguments.contains(capturedAgentDirectory)) + #expect(!preparedArguments.contains(capturedLaunchDirectory)) + #expect(!preparedArguments.contains(capturedArgumentDirectory)) + + let continuation = try #require( + workspace.restoredAgentSnapshotsByPanelId[panelId] + ) + let resumeCommand = try #require(continuation.resumeCommand) + #expect(resumeCommand.contains(trustedRemoteDirectory), Comment(rawValue: resumeCommand)) + #expect(!resumeCommand.contains(capturedAgentDirectory), Comment(rawValue: resumeCommand)) + #expect(!resumeCommand.contains(capturedLaunchDirectory), Comment(rawValue: resumeCommand)) + #expect(!resumeCommand.contains(capturedArgumentDirectory), Comment(rawValue: resumeCommand)) + + workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning) + #expect(workspace.restoredAgentSnapshotsByPanelId[panelId] == nil) + let retainedBinding = try #require( + workspace.sessionSnapshot(includeScrollback: false) + .panels.first(where: { $0.id == panelId })? + .terminal?.resumeBinding + ) + let retainedInput = try #require( + retainedBinding.inlineStartupInput(repairPortableAgentExecutable: false) + ) + #expect(retainedBinding.restoreWorkingDirectorySelection == .exact(trustedRemoteDirectory)) + #expect(retainedBinding.autoResume == false) + #expect(retainedInput.contains(trustedRemoteDirectory), Comment(rawValue: retainedInput)) + #expect(!retainedInput.contains(capturedAgentDirectory), Comment(rawValue: retainedInput)) + #expect(!retainedInput.contains(capturedLaunchDirectory), Comment(rawValue: retainedInput)) + #expect(!retainedInput.contains(capturedArgumentDirectory), Comment(rawValue: retainedInput)) + } + } + + @MainActor + @Test func hibernatedRemoteContinuationUsesOnlyTrustedReportedDirectory() throws { + let localWorkspaceDirectory = "/Users/alice/development" + let capturedAgentDirectory = "/Users/alice/hibernated-agent-cwd" + let capturedLaunchDirectory = "/Users/alice/hibernated-launch-cwd" + let capturedArgumentDirectory = "/Users/alice/hibernated-argument-cwd" + let trustedRemoteDirectory = "/home/remote/hibernated-project" + let remoteCommand = "ssh cmux-remote" + let sessionId = "codex-remote-hibernated-\(UUID().uuidString)" + let source = Workspace( + workingDirectory: localWorkspaceDirectory, + initialTerminalCommand: remoteCommand + ) + defer { source.teardownAllPanels() } + let sourcePanelId = try #require(source.focusedPanelId) + source.configureRemoteConnection( + remoteWorkspaceConfiguration(command: remoteCommand), + autoConnect: false + ) + #expect(source.updateRemotePanelDirectory( + panelId: sourcePanelId, + directory: trustedRemoteDirectory + )) + source.updatePanelShellActivityState(panelId: sourcePanelId, state: .commandRunning) + source.setRestoredAgentSnapshotForTesting( + SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionId, + workingDirectory: capturedAgentDirectory, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/usr/local/bin/codex", + arguments: [ + "/usr/local/bin/codex", + "-C\(capturedArgumentDirectory)", + "--model", + "test-model", + ], + workingDirectory: capturedLaunchDirectory, + environment: [:], + capturedAt: 1_777_777_777, + source: "process" + ) + ), + panelId: sourcePanelId + ) + + var snapshot = source.sessionSnapshot(includeScrollback: false) + let panelIndex = try #require(snapshot.panels.firstIndex { $0.id == sourcePanelId }) + var terminalSnapshot = try #require(snapshot.panels[panelIndex].terminal) + terminalSnapshot.hibernation = SessionAgentHibernationSnapshot( + hibernatedAt: 1_777_777_777, + lastActivityAt: 1_777_777_700 + ) + snapshot.panels[panelIndex].terminal = terminalSnapshot + + try withRestoredRemoteSurface( + snapshot, + sourcePanelId: sourcePanelId, + autoResumeAgentSessions: true, + agentHibernationPresentationVisible: false + ) { workspace, panelId, panel, restoreRecord in + #expect(panel.isAgentHibernated) + #expect(restoreRecord.workingDirectory == trustedRemoteDirectory) + #expect(restoreRecord.launchCommand?.workingDirectory == nil) + + let structuredArguments = [ + restoreRecord.launchCommand?.arguments.joined(separator: " "), + restoreRecord.preparedArguments?.joined(separator: " "), + ].compactMap { $0 }.joined(separator: " ") + #expect(!structuredArguments.contains(capturedAgentDirectory)) + #expect(!structuredArguments.contains(capturedLaunchDirectory)) + #expect(!structuredArguments.contains(capturedArgumentDirectory)) + + let hibernatedAgent = try #require(panel.agentHibernationState?.agent) + let resumeCommand = try #require(hibernatedAgent.resumeCommand) + #expect(resumeCommand.contains(trustedRemoteDirectory), Comment(rawValue: resumeCommand)) + #expect(!resumeCommand.contains(capturedAgentDirectory), Comment(rawValue: resumeCommand)) + #expect(!resumeCommand.contains(capturedLaunchDirectory), Comment(rawValue: resumeCommand)) + #expect(!resumeCommand.contains(capturedArgumentDirectory), Comment(rawValue: resumeCommand)) + + #expect(workspace.resumeAgentHibernation(panelId: panelId, focus: false)) + let queuedInput = try #require( + panel.surface.debugInitialInputForTesting() + ?? panel.surface.nextRuntimeInitialInput + ) + #expect(queuedInput.contains(sessionId), Comment(rawValue: queuedInput)) + // Hibernation queues the local `cmux restore` verb; the persisted + // restore record carries the trusted remote directory asserted above. + #expect(!queuedInput.contains(capturedAgentDirectory), Comment(rawValue: queuedInput)) + #expect(!queuedInput.contains(capturedLaunchDirectory), Comment(rawValue: queuedInput)) + #expect(!queuedInput.contains(capturedArgumentDirectory), Comment(rawValue: queuedInput)) + } + } + + @MainActor + func withRestoredRemoteSurface( + _ snapshot: SessionWorkspaceSnapshot, + sourcePanelId: UUID, + autoResumeAgentSessions: Bool, + agentHibernationPresentationVisible: Bool = true, + body: ( + _ workspace: Workspace, + _ panelId: UUID, + _ panel: TerminalPanel, + _ restoreRecord: ControlSurfaceRestoreRecord + ) throws -> T + ) throws -> T { + try withRestoredRemoteSurfaceSnapshot( + snapshot, + sourcePanelId: sourcePanelId, + autoResumeAgentSessions: autoResumeAgentSessions, + agentHibernationPresentationVisible: agentHibernationPresentationVisible + ) { workspace, panelId, panel, resumeSnapshot in + let restoreRecord = try #require(resumeSnapshot.restoreRecord) + return try body(workspace, panelId, panel, restoreRecord) + } + } + + @MainActor + func withRestoredRemoteSurfaceSnapshot( + _ snapshot: SessionWorkspaceSnapshot, + sourcePanelId: UUID, + autoResumeAgentSessions: Bool, + agentHibernationPresentationVisible: Bool = true, + body: ( + _ workspace: Workspace, + _ panelId: UUID, + _ panel: TerminalPanel, + _ resumeSnapshot: ControlSurfaceResumeSnapshot + ) throws -> T + ) throws -> T { + let defaultsName = "cmux-remote-restore-helper-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: defaultsName)) + defer { defaults.removePersistentDomain(forName: defaultsName) } + defaults.set( + autoResumeAgentSessions, + forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey + ) + + _ = NSApplication.shared + let previousAppDelegate = AppDelegate.shared + let app = AppDelegate() + defer { AppDelegate.shared = previousAppDelegate } + + let windowId = UUID() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(windowId.uuidString)") + let manager = TabManager( + autoWelcomeIfNeeded: false, + agentSessionAutoResumeDefaults: defaults + ) + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + TerminalController.shared.setActiveTabManager(manager) + defer { + TerminalController.shared.setActiveTabManager(nil) + app.unregisterMainWindowContextForTesting(windowId: windowId) + for workspace in manager.tabs { + workspace.teardownAllPanels() + } + window.orderOut(nil) + } + + let workspace = try #require(manager.selectedWorkspace) + workspace.setAgentHibernationAutoResumePresentationVisible( + agentHibernationPresentationVisible + ) + let restoredPanelIds = workspace.restoreSessionSnapshot(snapshot) + let panelId = try #require(restoredPanelIds[sourcePanelId]) + let panel = try #require(workspace.terminalPanel(for: panelId)) + let routing = ControlRoutingSelectors( + hasWindowIDParam: true, + windowID: windowId, + groupID: nil, + workspaceID: workspace.id, + surfaceID: panelId, + paneID: nil + ) + let resolution = TerminalController.shared.controlSurfaceResumeGet( + routing: routing, + explicitTargetID: panelId, + hasResolvedWindowID: true, + claimCheckpointID: nil, + claimSource: nil, + claimUpdatedAt: nil + ) + guard case .result(let result) = resolution else { + Issue.record("surface.resume.get failed: \(resolution)") + throw RemoteSurfaceRestoreTestError.resumeRecordUnavailable + } + return try body(workspace, panelId, panel, result) + } + + enum RemoteSurfaceRestoreTestError: Error { + case resumeRecordUnavailable + } + + func remoteWorkspaceConfiguration( + command: String, + preserveAfterTerminalExit: Bool = false, + persistentDaemonSlot: String? = nil + ) -> WorkspaceRemoteConfiguration { + WorkspaceRemoteConfiguration( + destination: "cmux-remote", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64_000, + relayID: "relay-remote-cwd-\(UUID().uuidString)", + relayToken: String(repeating: "a", count: 64), + localSocketPath: "/tmp/cmux-remote-cwd-\(UUID().uuidString).sock", + terminalStartupCommand: command, + preserveAfterTerminalExit: preserveAfterTerminalExit, + persistentDaemonSlot: persistentDaemonSlot + ) + } + + func restorableCodexAgent( + sessionId: String, + workingDirectory: String + ) -> SessionRestorableAgentSnapshot { + SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionId, + workingDirectory: workingDirectory, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/usr/local/bin/codex", + arguments: ["/usr/local/bin/codex"], + workingDirectory: workingDirectory, + environment: [:], + capturedAt: 1_777_777_777, + source: "process" + ) + ) + } +} diff --git a/cmuxTests/RemoteAgentRestoreWorkingDirectoryTests+Policy.swift b/cmuxTests/RemoteAgentRestoreWorkingDirectoryTests+Policy.swift new file mode 100644 index 000000000000..1275422ef80e --- /dev/null +++ b/cmuxTests/RemoteAgentRestoreWorkingDirectoryTests+Policy.swift @@ -0,0 +1,496 @@ +import AppKit +import CMUXAgentLaunch +import CmuxControlSocket +import CmuxCore +import CmuxSidebar +import Darwin +import Foundation +import Testing +@testable import CmuxTerminal + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif +@MainActor +extension RemoteAgentRestoreWorkingDirectoryTests { + @Test func unavailableRemoteAgentRetainsOnlyPersistentSSHReattach() throws { + let defaultsName = "cmux-remote-persistent-cwd-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: defaultsName)) + defer { defaults.removePersistentDomain(forName: defaultsName) } + defaults.set(true, forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) + + let capturedDirectory = "/Users/alice/persistent-agent-cwd" + let trustedRuntimeDirectory = "/home/remote/persistent-project" + let source = Workspace(agentSessionAutoResumeDefaults: defaults) + defer { source.teardownAllPanels() } + source.configureRemoteConnection( + remoteWorkspaceConfiguration( + command: SSHPTYAttachStartupCommandBuilder.command(), + preserveAfterTerminalExit: true, + persistentDaemonSlot: "remote-cwd-policy" + ), + autoConnect: false + ) + let sourcePanelId = try #require(source.focusedPanelId) + let persistentSessionID = Workspace.defaultSSHPTYSessionID( + workspaceId: source.id, + panelId: sourcePanelId + ) + #expect(source.updateRemotePanelDirectory( + panelId: sourcePanelId, + directory: trustedRuntimeDirectory + )) + source.updatePanelShellActivityState(panelId: sourcePanelId, state: .commandRunning) + source.setRestoredAgentSnapshotForTesting( + SessionRestorableAgentSnapshot( + kind: .grok, + sessionId: "persistent-grok-session", + workingDirectory: capturedDirectory, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "grok", + executablePath: "grok", + arguments: ["grok", "--cwd", capturedDirectory], + workingDirectory: capturedDirectory + ), + registration: .builtInGrok + ), + panelId: sourcePanelId + ) + let binding = SurfaceResumeBindingSnapshot( + kind: "grok", + command: "grok --resume persistent-grok-session --cwd '\(capturedDirectory)'", + cwd: capturedDirectory, + checkpointId: "persistent-grok-session", + source: "agent-hook", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "grok", + executablePath: "grok", + arguments: ["grok", "--cwd", capturedDirectory], + workingDirectory: capturedDirectory + ), + autoResume: true, + launchFlavor: .persistentSSH(SurfaceResumeRemoteContext( + workspaceID: source.id, + surfaceID: sourcePanelId, + persistentPTYSessionID: persistentSessionID + )) + ) + #expect(source.setSurfaceResumeBinding(binding, panelId: sourcePanelId)) + + let snapshot = source.sessionSnapshot(includeScrollback: false) + let restored = Workspace(agentSessionAutoResumeDefaults: defaults) + defer { restored.teardownAllPanels() } + let restoredPanelIds = restored.restoreSessionSnapshot(snapshot) + let restoredPanelId = try #require(restoredPanelIds[sourcePanelId]) + let restoredPanel = try #require(restored.terminalPanel(for: restoredPanelId)) + let retained = try #require(restored.restoredAgentSnapshotsByPanelId[restoredPanelId]) + + #expect(retained.restoreWorkingDirectorySelection == .unavailable) + #expect(retained.resumeCommand == nil) + #expect(retained.forkCommand == nil) + #expect( + restored.restoredAgentResumeStatesByPanelId[restoredPanelId] == .manualResumeAvailable + ) + #expect(restored.surfaceResumeBinding(panelId: restoredPanelId)?.launchFlavor.remoteContext != nil) + + let attachCommand = try #require(restoredPanel.surface.debugInitialCommand()) + #expect(attachCommand.contains("ssh-pty-attach"), Comment(rawValue: attachCommand)) + #expect(attachCommand.contains("--require-existing"), Comment(rawValue: attachCommand)) + #expect(!attachCommand.contains(capturedDirectory), Comment(rawValue: attachCommand)) + + // The attach launcher is itself `/bin/sh -c '