From cb3225c7760dc9cd263f3bc584e28ca7332f561b Mon Sep 17 00:00:00 2001 From: allo Date: Thu, 13 Aug 2026 00:31:52 +0800 Subject: [PATCH] resume: synthesize hookless directory-scoped continue bindings for remote agents (#7989) An agent launched inside a persistent-SSH workspace without relayed hooks leaves no resume binding, so a restore reattaches the PTY but never resumes the agent once the PTY is genuinely gone. Requiring `cmux hooks setup` on every remote host for every agent CLI is a real adoption cliff for multi-host users. This adds the Tier-1 flavor sketched in the issue discussion: when the snapshot still knows the agent kind and remote working directory, synthesize a directory-scoped continue binding from cmux's own inline templates (claude -> `claude --continue || claude`, codex -> `codex resume --last || codex`; kinds with no trustworthy sessionless continue synthesize nothing). Design points: - New binding source `remote-synthesized`, sharing the trust tier of process-detected bindings: the command is built exclusively from inline templates with no caller-supplied arguments, so it bypasses the signed approval store the same way. The gate for every other source is unchanged. - Reuses .persistentSSH(SurfaceResumeRemoteContext) - no new Codable case, no persistence-format risk. - Liveness-gated through the existing requireExisting attach pipeline: a live remote PTY is attach-only (the synthesized command is only injected once the PTY is confirmed ended), so restore can never race a live agent and create a duplicate writing the same directory. - Precedence: agent-hook / cli / process-detected bindings always win; synthesis only fills the gap, and only when the snapshot recorded a running agent and auto-resume is enabled. Known limitation (documented in code): directory-scoped continue resumes the most recent session in that directory, so a directory shared by multiple agents of the same kind can continue the wrong conversation. Hook-published bindings (Tier 2) remain the precise path and always take precedence. Refs #7989. --- Sources/ControlSurfaceResumeTarget.swift | 5 + Sources/RemoteAgentContinueSynthesizer.swift | 87 +++++ Sources/SessionPersistence.swift | 6 + ...faceResumeApprovalSigningSecretCache.swift | 9 +- ...orkspace+PersistentRemotePTYReattach.swift | 10 +- ...Workspace+RemoteSurfaceResumeBinding.swift | 2 +- Sources/Workspace.swift | 40 +- cmux.xcodeproj/project.pbxproj | 8 + .../RemoteAgentContinueSynthesizerTests.swift | 351 ++++++++++++++++++ 9 files changed, 512 insertions(+), 6 deletions(-) create mode 100644 Sources/RemoteAgentContinueSynthesizer.swift create mode 100644 cmuxTests/RemoteAgentContinueSynthesizerTests.swift diff --git a/Sources/ControlSurfaceResumeTarget.swift b/Sources/ControlSurfaceResumeTarget.swift index 9d248f98131..85d59bbc62e 100644 --- a/Sources/ControlSurfaceResumeTarget.swift +++ b/Sources/ControlSurfaceResumeTarget.swift @@ -343,6 +343,11 @@ extension TerminalController { guard let binding else { return nil } let trimmedKind = binding.kind?.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedKind = trimmedKind.flatMap { $0.isEmpty ? nil : $0 } ?? "command" + // Remote-synthesized bindings (#7989) intentionally map to `.direct`: + // they carry no session checkpoint, so `cmux surface resume show` + // presents the stored directory-level continue command verbatim under + // the agent kind instead of a typed `cmux restore ` + // selector. let mode: AgentRestoreRequestMode = binding.isAgentHookBinding ? .resumeAgent : .direct diff --git a/Sources/RemoteAgentContinueSynthesizer.swift b/Sources/RemoteAgentContinueSynthesizer.swift new file mode 100644 index 00000000000..f316ed6b443 --- /dev/null +++ b/Sources/RemoteAgentContinueSynthesizer.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Synthesizes a hookless, directory-scoped resume binding for a remote agent +/// (issue #7989, Tier 1). +/// +/// A remote host without cmux agent hooks never reports a session checkpoint, +/// so the strongest honest restore signal is "agent `` was running in +/// ``". This synthesizer turns that pair into a conservative +/// `cd && --continue || ` command bound to the panel's +/// persistent-SSH PTY, mirroring how `TmuxResumeParser` turns a locally +/// observed tmux client into a `process-detected` binding. +/// +/// Known limitation (accepted for hookless remotes): a directory-scoped +/// continue command resumes whatever conversation the agent considers most +/// recent for that directory. When several sessions of the same agent share +/// one working directory, the wrong conversation can be continued. +enum RemoteAgentContinueSynthesizer { + /// `SurfaceResumeBindingSnapshot.source` value for synthesized bindings; + /// must match `SurfaceResumeBindingSnapshot.isRemoteSynthesized`. + static let source = "remote-synthesized" + + /// Builds a directory-scoped continue binding, or nil when the agent kind + /// has no trustworthy sessionless continue invocation or the remote + /// working directory is unknown. + static func binding( + kind: RestorableAgentKind, + remoteWorkingDirectory: String?, + remoteContext: SurfaceResumeRemoteContext, + updatedAt: TimeInterval = Date().timeIntervalSince1970 + ) -> SurfaceResumeBindingSnapshot? { + guard let workingDirectory = normalized(remoteWorkingDirectory), + let continueCommand = directoryScopedContinueCommand(for: kind) else { + return nil + } + // Same cd guard as every other startup command + // (`TerminalStartupWorkingDirectoryPrefix`): tolerate a deleted saved + // directory instead of failing before the agent launches. The command + // runs on the remote host, so the local claude/codex wrapper-resolver + // tokens are deliberately not used here — mirroring + // `SurfaceResumeBindingSnapshot.remoteStartupInput()`, which renders + // remote commands with `repairPortableAgentExecutable: false`. + let command = TerminalStartupWorkingDirectoryPrefix.prefix( + continueCommand, + workingDirectory: workingDirectory + ) + return SurfaceResumeBindingSnapshot( + name: "\(kind.displayName) continue", + kind: kind.rawValue, + command: command, + cwd: workingDirectory, + source: source, + autoResume: true, + launchFlavor: .persistentSSH(remoteContext), + updatedAt: updatedAt + ) + } + + /// Sessionless continue templates. Deliberately conservative: only agents + /// with a documented directory-level continue invocation are covered; the + /// per-session commands in `docs/agent-hooks.md` all need a checkpoint id + /// that a hookless remote cannot provide. The `|| ` fallback starts + /// a fresh session when the agent has nothing to continue in that + /// directory, so restore never dead-ends on a continue error. + private static func directoryScopedContinueCommand( + for kind: RestorableAgentKind + ) -> String? { + switch kind { + case .claude: + // Continues the most recent conversation recorded for the current + // working directory (claude's session store is cwd-keyed). + return "claude --continue || claude" + case .codex: + // Resumes the most recently used codex session. + return "codex resume --last || codex" + default: + return nil + } + } + + private static func normalized(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } +} diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index a6ca087e395..faa5a3db5a2 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -363,6 +363,12 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { source == "cli" } + /// A directory-scoped continue binding synthesized by + /// `RemoteAgentContinueSynthesizer` for a hookless remote agent (#7989). + var isRemoteSynthesized: Bool { + source == "remote-synthesized" + } + var allowsAutomaticResume: Bool { autoResume == true } diff --git a/Sources/SurfaceResumeApprovalSigningSecretCache.swift b/Sources/SurfaceResumeApprovalSigningSecretCache.swift index c7083c58537..ae189a1bc9c 100644 --- a/Sources/SurfaceResumeApprovalSigningSecretCache.swift +++ b/Sources/SurfaceResumeApprovalSigningSecretCache.swift @@ -391,7 +391,14 @@ extension SurfaceResumeApprovalStore { private static func trustedBinding( from binding: SurfaceResumeBindingSnapshot ) -> SurfaceResumeBindingSnapshot? { - if binding.isProcessDetected { + if binding.isProcessDetected || binding.isRemoteSynthesized { + // Both sources are cmux's own observations rather than proposals + // from an arbitrary process: process-detected comes from scanning + // live local processes, and remote-synthesized commands are built + // exclusively from `RemoteAgentContinueSynthesizer`'s inline + // directory-level continue templates (no caller-supplied + // arguments), so they share the same trust tier and bypass the + // signed approval store. var trustedBinding = binding trustedBinding.autoResume = true trustedBinding.approvalPolicy = .auto diff --git a/Sources/Workspace+PersistentRemotePTYReattach.swift b/Sources/Workspace+PersistentRemotePTYReattach.swift index eba4f61b2d5..ad75408f3fb 100644 --- a/Sources/Workspace+PersistentRemotePTYReattach.swift +++ b/Sources/Workspace+PersistentRemotePTYReattach.swift @@ -63,6 +63,14 @@ extension Workspace { panelID: panelId, persistentPTYSessionID: sessionID ) + // A synthesized directory-level continue command (#7989) has no + // session checkpoint the remote once-guard could reconcile + // against, so only inject it once the PTY is confirmed ended; + // a live PTY is attach-only. + let injectableResumeCommand = + resumeBinding?.isRemoteSynthesized == true && !sessionEnded + ? nil + : approvedResumeCommand let restartedShellCommand = sessionEnded ? configuration.relayPort.map { SSHPTYAttachStartupCommandBuilder.restoredRemoteShellCommand( @@ -73,7 +81,7 @@ extension Workspace { : nil command = remotePTYAttachStartupCommand( sessionID: sessionID, - remoteCommand: approvedResumeCommand ?? restartedShellCommand, + remoteCommand: injectableResumeCommand ?? restartedShellCommand, requireExisting: !sessionEnded ) } else { diff --git a/Sources/Workspace+RemoteSurfaceResumeBinding.swift b/Sources/Workspace+RemoteSurfaceResumeBinding.swift index 7eb72437a64..949d59cd7dd 100644 --- a/Sources/Workspace+RemoteSurfaceResumeBinding.swift +++ b/Sources/Workspace+RemoteSurfaceResumeBinding.swift @@ -84,7 +84,7 @@ extension Workspace { ) else { return nil } - if effectiveBinding.isAgentHookBinding, + if effectiveBinding.isAgentHookBinding || effectiveBinding.isRemoteSynthesized, !AgentSessionAutoResumeSettings.isEnabled(defaults: agentSessionAutoResumeDefaults) { return nil } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index b29ab2e64b8..b0b80c256bf 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1411,13 +1411,46 @@ extension Workspace { persistentPTYSessionID: restoredRemotePTYSessionID, restoresRemoteTerminal: restoresRemoteWorkspaceTerminalSnapshot ) + // Tier-1 hookless remote resume (#7989): a remote agent without + // relayed hooks leaves no resume binding, so a persistent-SSH + // restore reattaches the PTY but never resumes the agent when the + // PTY is gone. When the snapshot still knows the agent kind and + // remote working directory, synthesize a directory-scoped + // continue binding. An existing binding (agent-hook / cli / + // process-detected) always wins; the synthesized one only fills + // the gap. + let synthesizedRemoteContinueBinding: SurfaceResumeBindingSnapshot? = { + guard locatedResumeBinding == nil, + restoresRemoteWorkspaceTerminalSnapshot, + shouldAutoResumeAgent, + let restoredRemotePTYSessionID, + let restorableAgent else { + return nil + } + return RemoteAgentContinueSynthesizer.binding( + kind: restorableAgent.kind, + remoteWorkingDirectory: restorableAgent.workingDirectory + ?? restorableAgent.launchCommand?.workingDirectory + ?? (restoresUntrustedSavedDirectory ? nil : snapshot.terminal?.workingDirectory), + remoteContext: SurfaceResumeRemoteContext( + workspaceID: restoredResumeSnapshotWorkspaceID, + surfaceID: snapshot.id, + persistentPTYSessionID: restoredRemotePTYSessionID + ) + ) + }() let resumeBinding = Self.resumeBindingForSessionRestore( - locatedResumeBinding, + locatedResumeBinding ?? synthesizedRemoteContinueBinding, restorableAgent: restorableAgent ) let resumeBindingForStartup = restoredHibernation != nil || - (resumeBinding?.isProcessDetected == true && resumeBinding?.autoResume != true) + (resumeBinding?.isProcessDetected == true && resumeBinding?.autoResume != true) || + // A synthesized continue binding is only as fresh as the + // wasAgentRunning evidence captured with the snapshot; once + // the remote agent exited (or auto-resume is disabled), a + // previously persisted synthesized binding must not replay. + (resumeBinding?.isRemoteSynthesized == true && !shouldAutoResumeAgent) ? nil : resumeBinding let effectiveResumeBindingForStartup = sessionRestorePolicy.approvedSurfaceResumeBinding( @@ -1582,7 +1615,8 @@ extension Workspace { localWorkingDirectory ?? hostShellWorkingDirectory let restoredAgentWillRunStartupCommand = restoredPersistentSSHResumeCommand != nil && - resumeBinding?.isAgentHookBinding == true + (resumeBinding?.isAgentHookBinding == true || + resumeBinding?.isRemoteSynthesized == true) let restoredAgentWillRunStartupInput = restoredAgentResumeLaunch?.initialInput != nil || (restoredBindingLaunch?.initialInput != nil && resumeBinding?.isAgentHookBinding == true) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 26d19cd278c..6dee690cc54 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1881,6 +1881,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 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 */; }; + 9DCE2D1C84A5FB5D00BF4437 /* RemoteAgentContinueSynthesizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72D87115779C1B5E4B9ADB2C /* RemoteAgentContinueSynthesizerTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.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 */; }; @@ -2186,6 +2187,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 842300000000000000000001 /* SurfaceResumeExitedAgentLivenessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842300000000000000000002 /* SurfaceResumeExitedAgentLivenessTests.swift */; }; 7989B0027989B0027989B002 /* SurfaceResumeLaunchFlavor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7989B1027989B1027989B102 /* SurfaceResumeLaunchFlavor.swift */; }; 7989B0037989B0037989B003 /* SurfaceResumeRemoteContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7989B1037989B1037989B103 /* SurfaceResumeRemoteContext.swift */; }; + 55385DA3BD1729F18132159B /* RemoteAgentContinueSynthesizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 447566488217DA11300AADDE /* RemoteAgentContinueSynthesizer.swift */; }; F27B00000000000000000001 /* SurfaceResumeRunPromptBatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = F27B00000000000000000002 /* SurfaceResumeRunPromptBatch.swift */; }; A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; }; C51A73B40000000000000002 /* SurfaceTabBarButtonConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */; }; @@ -4632,6 +4634,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 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 = ""; }; + 72D87115779C1B5E4B9ADB2C /* RemoteAgentContinueSynthesizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteAgentContinueSynthesizerTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.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 = ""; }; @@ -4930,6 +4933,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 842300000000000000000002 /* SurfaceResumeExitedAgentLivenessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeExitedAgentLivenessTests.swift; sourceTree = ""; }; 7989B1027989B1027989B102 /* SurfaceResumeLaunchFlavor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeLaunchFlavor.swift; sourceTree = ""; }; 7989B1037989B1037989B103 /* SurfaceResumeRemoteContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeRemoteContext.swift; sourceTree = ""; }; + 447566488217DA11300AADDE /* RemoteAgentContinueSynthesizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteAgentContinueSynthesizer.swift; sourceTree = ""; }; F27B00000000000000000002 /* SurfaceResumeRunPromptBatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeRunPromptBatch.swift; sourceTree = ""; }; A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = ""; }; C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceTabBarButtonConfiguration.swift; sourceTree = ""; }; @@ -7259,6 +7263,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 7989B1017989B1017989B101 /* SurfaceResumeBindingSnapshot+Remote.swift */, 7989B1027989B1027989B102 /* SurfaceResumeLaunchFlavor.swift */, 7989B1037989B1037989B103 /* SurfaceResumeRemoteContext.swift */, + 447566488217DA11300AADDE /* RemoteAgentContinueSynthesizer.swift */, A74770010000000000000006 /* SessionConfigFrameEntry.swift */, A74770010000000000000008 /* SessionConfigFrameRing.swift */, A74770010000000000000004 /* SessionDisplaySnapshot.swift */, @@ -7747,6 +7752,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F83620010000000000000002 /* PortScannerTTYFreshnessTests.swift */, B79500320000000000000002 /* PortScannerIdentityContinuityTests.swift */, F6572003A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift */, + 72D87115779C1B5E4B9ADB2C /* RemoteAgentContinueSynthesizerTests.swift */, 7989A0027989A0027989A002 /* RemoteResumeBindingTests.swift */, 812600000000000000000004 /* SessionRemoteWorkspaceMoshRestoreTests.swift */, F6572013A1B2C3D4E5F60718 /* SurfaceResumeBindingCodexUpdateCheckTests.swift */, @@ -10214,6 +10220,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F6572000A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift in Sources */, 7989B0027989B0027989B002 /* SurfaceResumeLaunchFlavor.swift in Sources */, 7989B0037989B0037989B003 /* SurfaceResumeRemoteContext.swift in Sources */, + 55385DA3BD1729F18132159B /* RemoteAgentContinueSynthesizer.swift in Sources */, F27B00000000000000000001 /* SurfaceResumeRunPromptBatch.swift in Sources */, A5001303 /* SurfaceSearchOverlay.swift in Sources */, C51A73B40000000000000002 /* SurfaceTabBarButtonConfiguration.swift in Sources */, @@ -11372,6 +11379,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 850000000000000000000002 /* SessionIndexTableViewportTests.swift in Sources */, 8A3392FE64E0605D942213D1 /* SessionIndexViewTests.swift in Sources */, F6572002A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift in Sources */, + 9DCE2D1C84A5FB5D00BF4437 /* RemoteAgentContinueSynthesizerTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, 812600000000000000000003 /* SessionRemoteWorkspaceMoshRestoreTests.swift in Sources */, 806600000000000000000002 /* SessionRestorableAgentSnapshotPermissionModeTests.swift in Sources */, diff --git a/cmuxTests/RemoteAgentContinueSynthesizerTests.swift b/cmuxTests/RemoteAgentContinueSynthesizerTests.swift new file mode 100644 index 00000000000..d2e75a52233 --- /dev/null +++ b/cmuxTests/RemoteAgentContinueSynthesizerTests.swift @@ -0,0 +1,351 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Unit coverage for the Tier-1 hookless remote resume binding synthesis +/// (issue #7989): `RemoteAgentContinueSynthesizer`, the +/// `isRemoteSynthesized` source predicate, the approval-store trust tier, +/// the session-restore reconcile pass-through, and Codable persistence of +/// synthesized bindings. +@Suite struct RemoteAgentContinueSynthesizerTests { + private static func remoteContext( + persistentPTYSessionID: String = "pty-session-1" + ) -> SurfaceResumeRemoteContext { + SurfaceResumeRemoteContext( + workspaceID: UUID(uuidString: "11111111-2222-3333-4444-555555555555")!, + surfaceID: UUID(uuidString: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")!, + persistentPTYSessionID: persistentPTYSessionID + ) + } + + // MARK: - Command synthesis + + @Test func claudeBindingUsesCdGuardedDirectoryScopedContinueWithFreshSessionFallback() throws { + let binding = try #require(RemoteAgentContinueSynthesizer.binding( + kind: .claude, + remoteWorkingDirectory: "/home/user/proj", + remoteContext: Self.remoteContext(), + updatedAt: 123 + )) + + #expect( + binding.command + == "cd -- '/home/user/proj' 2>/dev/null || [ ! -d '/home/user/proj' ] && claude --continue || claude" + ) + #expect(binding.name == "Claude Code continue") + #expect(binding.kind == "claude") + #expect(binding.cwd == "/home/user/proj") + #expect(binding.source == "remote-synthesized") + #expect(binding.isRemoteSynthesized) + #expect(binding.autoResume == true) + #expect(binding.allowsAutomaticResume) + #expect(binding.updatedAt == 123) + // A synthesized binding carries no checkpoint, hook environment, or + // launch capture: `cmux surface resume show` must map it to `.direct` + // (not `.resumeAgent`) and the command must replay verbatim. + #expect(binding.checkpointId == nil) + #expect(binding.environment == nil) + #expect(binding.launchCommand == nil) + #expect(!binding.isAgentHookBinding) + } + + @Test func codexBindingUsesResumeLastWithFreshSessionFallback() throws { + let binding = try #require(RemoteAgentContinueSynthesizer.binding( + kind: .codex, + remoteWorkingDirectory: "/srv/app", + remoteContext: Self.remoteContext() + )) + + #expect( + binding.command + == "cd -- '/srv/app' 2>/dev/null || [ ! -d '/srv/app' ] && codex resume --last || codex" + ) + #expect(binding.name == "Codex continue") + #expect(binding.kind == "codex") + #expect(binding.source == "remote-synthesized") + #expect(binding.autoResume == true) + } + + @Test func bindingIsScopedToThePersistentSSHSessionThatOwnsIt() throws { + let context = Self.remoteContext(persistentPTYSessionID: "pty-abc") + let binding = try #require(RemoteAgentContinueSynthesizer.binding( + kind: .claude, + remoteWorkingDirectory: "/home/user/proj", + remoteContext: context + )) + + #expect(binding.launchFlavor == .persistentSSH(context)) + #expect(binding.launchFlavor.remoteContext == context) + #expect(binding.launchFlavor.executionLocationRawValue == "remote_ssh") + #expect(!binding.usesLocalRestoreVerb) + } + + @Test(arguments: [ + RestorableAgentKind.grok, .pi, .amp, .cursor, .gemini, .kiro, + .antigravity, .opencode, .rovodev, .hermesAgent, .copilot, + .codebuddy, .factory, .qoder, .kimi, .ollama, .custom("acme-agent"), + ]) + func kindsWithoutASessionlessContinueInvocationSynthesizeNothing( + kind: RestorableAgentKind + ) { + #expect(RemoteAgentContinueSynthesizer.binding( + kind: kind, + remoteWorkingDirectory: "/home/user/proj", + remoteContext: Self.remoteContext() + ) == nil) + } + + @Test(arguments: [String?.none, "", " ", "\n\t"]) + func missingOrBlankRemoteWorkingDirectorySynthesizesNothing(cwd: String?) { + #expect(RemoteAgentContinueSynthesizer.binding( + kind: .claude, + remoteWorkingDirectory: cwd, + remoteContext: Self.remoteContext() + ) == nil) + } + + @Test func workingDirectoryIsTrimmedBeforeSynthesis() throws { + let binding = try #require(RemoteAgentContinueSynthesizer.binding( + kind: .claude, + remoteWorkingDirectory: " /home/user/proj \n", + remoteContext: Self.remoteContext() + )) + + #expect(binding.cwd == "/home/user/proj") + #expect(binding.command.hasPrefix("cd -- '/home/user/proj' 2>/dev/null")) + } + + // MARK: - Quoting safety + + @Test func workingDirectoryWithSpacesIsSingleQuoted() throws { + let binding = try #require(RemoteAgentContinueSynthesizer.binding( + kind: .claude, + remoteWorkingDirectory: "/tmp/my project", + remoteContext: Self.remoteContext() + )) + + #expect( + binding.command + == "cd -- '/tmp/my project' 2>/dev/null || [ ! -d '/tmp/my project' ] && claude --continue || claude" + ) + } + + @Test func workingDirectoryWithSingleQuoteCannotEscapeTheQuoting() throws { + let binding = try #require(RemoteAgentContinueSynthesizer.binding( + kind: .codex, + remoteWorkingDirectory: "/tmp/it's here", + remoteContext: Self.remoteContext() + )) + + let quoted = #"'/tmp/it'\''s here'"# + #expect( + binding.command + == "cd -- \(quoted) 2>/dev/null || [ ! -d \(quoted) ] && codex resume --last || codex" + ) + } + + @Test func nonASCIIWorkingDirectoryUsesASCIIPrintfSubstitution() throws { + let cwd = "/tmp/项目" + let binding = try #require(RemoteAgentContinueSynthesizer.binding( + kind: .claude, + remoteWorkingDirectory: cwd, + remoteContext: Self.remoteContext() + )) + + // The shared quoter renders non-ASCII paths as an ASCII-only + // `"$(printf '\ooo…')"` substitution so the command survives any + // remote locale; the raw bytes must not appear in the command. + let quoted = TerminalStartupShellQuoting.singleQuoted(cwd) + #expect(quoted.hasPrefix(#""$(printf '"#)) + #expect( + binding.command + == "cd -- \(quoted) 2>/dev/null || [ ! -d \(quoted) ] && claude --continue || claude" + ) + #expect(!binding.command.contains("项目")) + #expect(binding.cwd == cwd, "cwd stores the raw path; only the command is quoted") + } + + // MARK: - Source predicate + + @Test func isRemoteSynthesizedMatchesTheSynthesizerSourceExactly() { + #expect(RemoteAgentContinueSynthesizer.source == "remote-synthesized") + + func snapshot(source: String?) -> SurfaceResumeBindingSnapshot { + SurfaceResumeBindingSnapshot(command: "claude --continue || claude", source: source) + } + + #expect(snapshot(source: "remote-synthesized").isRemoteSynthesized) + // Snapshot init trims the source before storing it. + #expect(snapshot(source: " remote-synthesized \n").isRemoteSynthesized) + #expect(!snapshot(source: "Remote-Synthesized").isRemoteSynthesized) + #expect(!snapshot(source: "remote-synthesized-2").isRemoteSynthesized) + #expect(!snapshot(source: nil).isRemoteSynthesized) + } + + @Test(arguments: ["agent-hook", "process-detected", "cli", "remote-synthesized"]) + func sourcePredicatesAreMutuallyExclusive(source: String) { + let binding = SurfaceResumeBindingSnapshot( + command: "claude --continue || claude", + source: source + ) + + let predicates = [ + binding.isAgentHookBinding, + binding.isProcessDetected, + binding.isCLIBinding, + binding.isRemoteSynthesized, + ] + #expect(predicates.filter { $0 }.count == 1, "exactly one predicate matches \(source)") + #expect(binding.isRemoteSynthesized == (source == "remote-synthesized")) + } + + // MARK: - Approval trust tier + + @Test func remoteSynthesizedBindingResolvesTrustWithoutSigningSecret() throws { + let binding = try #require(RemoteAgentContinueSynthesizer.binding( + kind: .claude, + remoteWorkingDirectory: "/home/user/proj", + remoteContext: Self.remoteContext() + )) + + // Same tier as process-detected: cmux's own observation, never a + // proposal from an arbitrary process, so approval must resolve + // even while the Keychain signing secret is still pending. + let result = SurfaceResumeApprovalStore.approvalProposalContext( + for: binding, + signingSecretResolution: .pending + ) + guard case let .resolved(context) = result else { + Issue.record("remote-synthesized must not depend on the signing secret") + return + } + #expect(context.effectiveBinding.allowsAutomaticResume) + #expect(context.effectiveBinding.approvalPolicy == .auto) + #expect(context.effectiveBinding.approvalRecordId == nil) + #expect(context.existingRecord == nil) + #expect(context.effectiveBinding.command == binding.command) + } + + @Test func remoteSynthesizedTrustForcesAutoResumeEvenWhenUnsetOnTheBinding() { + let binding = SurfaceResumeBindingSnapshot( + kind: "claude", + command: "claude --continue || claude", + cwd: "/home/user/proj", + source: "remote-synthesized", + autoResume: nil, + launchFlavor: .persistentSSH(Self.remoteContext()) + ) + + let effective = SurfaceResumeApprovalStore.bindingWithoutStoredApproval(to: binding) + #expect(effective.allowsAutomaticResume) + #expect(effective.approvalPolicy == .auto) + #expect(effective.approvalRecordId == nil) + } + + @Test func untrustedSourcesStillRequireStoredApproval() { + let binding = SurfaceResumeBindingSnapshot( + kind: "claude", + command: "claude --continue || claude", + cwd: "/home/user/proj", + source: "cli", + autoResume: true, + approvalRecordId: "unverified-record", + launchFlavor: .persistentSSH(Self.remoteContext()) + ) + + // Widening the trust tier to remote-synthesized must not loosen the + // gate for any other source: without a verified record, a cli + // proposal is demoted to manual. + let effective = SurfaceResumeApprovalStore.bindingWithoutStoredApproval(to: binding) + #expect(!effective.allowsAutomaticResume) + #expect(effective.approvalPolicy == .manual) + #expect(effective.approvalRecordId == nil) + } + + // MARK: - Session-restore reconcile + + @Test func sessionRestoreReconcilePassesSynthesizedBindingsThroughUntouched() throws { + let binding = try #require(RemoteAgentContinueSynthesizer.binding( + kind: .claude, + remoteWorkingDirectory: "/home/user/proj", + remoteContext: Self.remoteContext() + )) + let restorableAgent = SessionRestorableAgentSnapshot( + kind: .claude, + sessionId: "a22293b7-bcef-4707-8439-2f538c8517a4", + workingDirectory: "/somewhere/else", + launchCommand: nil + ) + + // The checkpoint-based cwd retargeting in resumeBindingForSessionRestore + // is agent-hook-only; a synthesized binding has no checkpoint to + // reconcile against and must keep its own directory and command. + let reconciled = Workspace.resumeBindingForSessionRestore( + binding, + restorableAgent: restorableAgent + ) + #expect(reconciled == binding) + } + + @Test func sessionRestoreReconcileDoesNotInventABindingFromAgentEvidenceAlone() { + let restorableAgent = SessionRestorableAgentSnapshot( + kind: .claude, + sessionId: "a22293b7-bcef-4707-8439-2f538c8517a4", + workingDirectory: "/home/user/proj", + launchCommand: nil + ) + + // Synthesis happens upstream (createPanel) and only when no binding + // was located; the reconcile helper itself must stay nil-preserving. + #expect(Workspace.resumeBindingForSessionRestore( + nil, + restorableAgent: restorableAgent + ) == nil) + } + + // MARK: - Remote startup input + + @Test func remoteStartupInputReplaysTheSynthesizedCommandVerbatim() throws { + let binding = try #require(RemoteAgentContinueSynthesizer.binding( + kind: .codex, + remoteWorkingDirectory: "/srv/app", + remoteContext: Self.remoteContext() + )) + + // The command runs on the remote host: no local restore-CLI verb, no + // wrapper-shim environment, no /usr/bin/env prefix — the stored + // command plus a trailing newline is the whole PTY input. + #expect(binding.remoteStartupInput() == binding.command + "\n") + } + + // MARK: - Codable persistence + + @Test func synthesizedBindingRoundTripsThroughPersistence() throws { + let context = Self.remoteContext(persistentPTYSessionID: "pty-roundtrip") + let binding = try #require(RemoteAgentContinueSynthesizer.binding( + kind: .codex, + remoteWorkingDirectory: "/srv/app with 'quotes'", + remoteContext: context, + updatedAt: 42 + )) + + let decoded = try JSONDecoder().decode( + SurfaceResumeBindingSnapshot.self, + from: JSONEncoder().encode(binding) + ) + + #expect(decoded == binding) + #expect(decoded.isRemoteSynthesized) + #expect(decoded.launchFlavor == .persistentSSH(context)) + #expect(decoded.launchFlavor.remoteContext?.persistentPTYSessionID == "pty-roundtrip") + #expect(!decoded.wasDecodedWithoutLaunchFlavor) + #expect(decoded.command == binding.command) + #expect(decoded.autoResume == true) + #expect(decoded.updatedAt == 42) + } +}