From 77771a71487805fe10a10f89eaec05ddadaacaa6 Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Fri, 21 Mar 2025 21:42:11 +1100
Subject: [PATCH] chore: add mutagen prompting gRPC

---
 .../VPNLib/FileSync/FileSyncDaemon.swift      |  14 +-
 .../VPNLib/FileSync/FileSyncPrompting.swift   |  53 +++
 .../VPNLib/FileSync/MutagenConvert.swift      |  23 +
 .../service_prompting_prompting.grpc.swift    | 421 ++++++++++++++++++
 .../service_prompting_prompting.pb.swift      | 279 ++++++++++++
 .../service_prompting_prompting.proto         |  80 ++++
 scripts/mutagen-proto.sh                      |  32 +-
 7 files changed, 883 insertions(+), 19 deletions(-)
 create mode 100644 Coder-Desktop/VPNLib/FileSync/FileSyncPrompting.swift
 create mode 100644 Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.grpc.swift
 create mode 100644 Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.pb.swift
 create mode 100644 Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.proto

diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
index 00633744..eafd4dc7 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
@@ -19,7 +19,7 @@ public protocol FileSyncDaemon: ObservableObject {
 
 @MainActor
 public class MutagenDaemon: FileSyncDaemon {
-    private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "mutagen")
+    let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "mutagen")
 
     @Published public var state: DaemonState = .stopped {
         didSet {
@@ -42,9 +42,9 @@ public class MutagenDaemon: FileSyncDaemon {
     private let mutagenDaemonSocket: URL
 
     // Non-nil when the daemon is running
+    var client: DaemonClient?
     private var group: MultiThreadedEventLoopGroup?
     private var channel: GRPCChannel?
-    private var client: DaemonClient?
 
     // Protect start & stop transitions against re-entrancy
     private let transition = AsyncSemaphore(value: 1)
@@ -171,7 +171,8 @@ public class MutagenDaemon: FileSyncDaemon {
             )
             client = DaemonClient(
                 mgmt: Daemon_DaemonAsyncClient(channel: channel!),
-                sync: Synchronization_SynchronizationAsyncClient(channel: channel!)
+                sync: Synchronization_SynchronizationAsyncClient(channel: channel!),
+                prompt: Prompting_PromptingAsyncClient(channel: channel!)
             )
             logger.info(
                 "Successfully connected to mutagen daemon, socket: \(self.mutagenDaemonSocket.path, privacy: .public)"
@@ -301,6 +302,7 @@ public class MutagenDaemon: FileSyncDaemon {
 struct DaemonClient {
     let mgmt: Daemon_DaemonAsyncClient
     let sync: Synchronization_SynchronizationAsyncClient
+    let prompt: Prompting_PromptingAsyncClient
 }
 
 public enum DaemonState {
@@ -342,6 +344,8 @@ public enum DaemonError: Error {
     case connectionFailure(Error)
     case terminatedUnexpectedly
     case grpcFailure(Error)
+    case invalidGrpcResponse(String)
+    case unexpectedStreamClosure
 
     public var description: String {
         switch self {
@@ -355,6 +359,10 @@ public enum DaemonError: Error {
             "The daemon must be started first"
         case let .grpcFailure(error):
             "Failed to communicate with daemon: \(error)"
+        case let .invalidGrpcResponse(response):
+            "Invalid gRPC response: \(response)"
+        case .unexpectedStreamClosure:
+            "Unexpected stream closure"
         }
     }
 
diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncPrompting.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncPrompting.swift
new file mode 100644
index 00000000..d5a49b42
--- /dev/null
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncPrompting.swift
@@ -0,0 +1,53 @@
+import GRPC
+
+extension MutagenDaemon {
+    typealias PromptStream = GRPCAsyncBidirectionalStreamingCall<Prompting_HostRequest, Prompting_HostResponse>
+
+    func host(allowPrompts: Bool = true) async throws(DaemonError) -> (PromptStream, identifier: String) {
+        let stream = client!.prompt.makeHostCall()
+
+        do {
+            try await stream.requestStream.send(.with { req in req.allowPrompts = allowPrompts })
+        } catch {
+            throw .grpcFailure(error)
+        }
+
+        // We can't make call `makeAsyncIterator` more than once
+        // (as a for-loop would do implicitly)
+        var iter = stream.responseStream.makeAsyncIterator()
+
+        let initResp: Prompting_HostResponse?
+        do {
+            initResp = try await iter.next()
+        } catch {
+            throw .grpcFailure(error)
+        }
+        guard let initResp else {
+            throw .unexpectedStreamClosure
+        }
+        try initResp.ensureValid(first: true, allowPrompts: allowPrompts)
+
+        Task.detached(priority: .background) {
+            do {
+                while let msg = try await iter.next() {
+                    try msg.ensureValid(first: false, allowPrompts: allowPrompts)
+                    var reply: Prompting_HostRequest = .init()
+                    if msg.isPrompt {
+                        // Handle SSH key prompts
+                        if msg.message.contains("yes/no/[fingerprint]") {
+                            reply.response = "yes"
+                        }
+                        // Any other messages that require a non-empty response will
+                        // cause the create op to fail, showing an error. This is ok for now.
+                    }
+                    try await stream.requestStream.send(reply)
+                }
+            } catch let error as GRPCStatus where error.code == .cancelled {
+                return
+            } catch {
+                self.logger.critical("Prompt stream failed: \(error)")
+            }
+        }
+        return (stream, identifier: initResp.identifier)
+    }
+}
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift b/Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift
index 7afefee1..8a59b238 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift
@@ -57,3 +57,26 @@ func accumulateErrors(from state: Synchronization_State) -> [FileSyncError] {
 func humanReadableBytes(_ bytes: UInt64) -> String {
     ByteCountFormatter().string(fromByteCount: Int64(bytes))
 }
+
+extension Prompting_HostResponse {
+    func ensureValid(first: Bool, allowPrompts: Bool) throws(DaemonError) {
+        if first {
+            if identifier.isEmpty {
+                throw .invalidGrpcResponse("empty prompter identifier")
+            }
+            if isPrompt {
+                throw .invalidGrpcResponse("unexpected message type specification")
+            }
+            if !message.isEmpty {
+                throw .invalidGrpcResponse("unexpected message")
+            }
+        } else {
+            if !identifier.isEmpty {
+                throw .invalidGrpcResponse("unexpected prompter identifier")
+            }
+            if isPrompt, !allowPrompts {
+                throw .invalidGrpcResponse("disallowed prompt message type")
+            }
+        }
+    }
+}
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.grpc.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.grpc.swift
new file mode 100644
index 00000000..a79eb510
--- /dev/null
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.grpc.swift
@@ -0,0 +1,421 @@
+//
+// DO NOT EDIT.
+// swift-format-ignore-file
+//
+// Generated by the protocol buffer compiler.
+// Source: service_prompting_prompting.proto
+//
+import GRPC
+import NIO
+import NIOConcurrencyHelpers
+import SwiftProtobuf
+
+
+/// Prompting allows clients to host and request prompting.
+///
+/// Usage: instantiate `Prompting_PromptingClient`, then call methods of this protocol to make API calls.
+internal protocol Prompting_PromptingClientProtocol: GRPCClient {
+  var serviceName: String { get }
+  var interceptors: Prompting_PromptingClientInterceptorFactoryProtocol? { get }
+
+  func host(
+    callOptions: CallOptions?,
+    handler: @escaping (Prompting_HostResponse) -> Void
+  ) -> BidirectionalStreamingCall<Prompting_HostRequest, Prompting_HostResponse>
+
+  func prompt(
+    _ request: Prompting_PromptRequest,
+    callOptions: CallOptions?
+  ) -> UnaryCall<Prompting_PromptRequest, Prompting_PromptResponse>
+}
+
+extension Prompting_PromptingClientProtocol {
+  internal var serviceName: String {
+    return "prompting.Prompting"
+  }
+
+  /// Host allows clients to perform prompt hosting.
+  ///
+  /// Callers should use the `send` method on the returned object to send messages
+  /// to the server. The caller should send an `.end` after the final message has been sent.
+  ///
+  /// - Parameters:
+  ///   - callOptions: Call options.
+  ///   - handler: A closure called when each response is received from the server.
+  /// - Returns: A `ClientStreamingCall` with futures for the metadata and status.
+  internal func host(
+    callOptions: CallOptions? = nil,
+    handler: @escaping (Prompting_HostResponse) -> Void
+  ) -> BidirectionalStreamingCall<Prompting_HostRequest, Prompting_HostResponse> {
+    return self.makeBidirectionalStreamingCall(
+      path: Prompting_PromptingClientMetadata.Methods.host.path,
+      callOptions: callOptions ?? self.defaultCallOptions,
+      interceptors: self.interceptors?.makeHostInterceptors() ?? [],
+      handler: handler
+    )
+  }
+
+  /// Prompt performs prompting using a specific prompter.
+  ///
+  /// - Parameters:
+  ///   - request: Request to send to Prompt.
+  ///   - callOptions: Call options.
+  /// - Returns: A `UnaryCall` with futures for the metadata, status and response.
+  internal func prompt(
+    _ request: Prompting_PromptRequest,
+    callOptions: CallOptions? = nil
+  ) -> UnaryCall<Prompting_PromptRequest, Prompting_PromptResponse> {
+    return self.makeUnaryCall(
+      path: Prompting_PromptingClientMetadata.Methods.prompt.path,
+      request: request,
+      callOptions: callOptions ?? self.defaultCallOptions,
+      interceptors: self.interceptors?.makePromptInterceptors() ?? []
+    )
+  }
+}
+
+@available(*, deprecated)
+extension Prompting_PromptingClient: @unchecked Sendable {}
+
+@available(*, deprecated, renamed: "Prompting_PromptingNIOClient")
+internal final class Prompting_PromptingClient: Prompting_PromptingClientProtocol {
+  private let lock = Lock()
+  private var _defaultCallOptions: CallOptions
+  private var _interceptors: Prompting_PromptingClientInterceptorFactoryProtocol?
+  internal let channel: GRPCChannel
+  internal var defaultCallOptions: CallOptions {
+    get { self.lock.withLock { return self._defaultCallOptions } }
+    set { self.lock.withLockVoid { self._defaultCallOptions = newValue } }
+  }
+  internal var interceptors: Prompting_PromptingClientInterceptorFactoryProtocol? {
+    get { self.lock.withLock { return self._interceptors } }
+    set { self.lock.withLockVoid { self._interceptors = newValue } }
+  }
+
+  /// Creates a client for the prompting.Prompting service.
+  ///
+  /// - Parameters:
+  ///   - channel: `GRPCChannel` to the service host.
+  ///   - defaultCallOptions: Options to use for each service call if the user doesn't provide them.
+  ///   - interceptors: A factory providing interceptors for each RPC.
+  internal init(
+    channel: GRPCChannel,
+    defaultCallOptions: CallOptions = CallOptions(),
+    interceptors: Prompting_PromptingClientInterceptorFactoryProtocol? = nil
+  ) {
+    self.channel = channel
+    self._defaultCallOptions = defaultCallOptions
+    self._interceptors = interceptors
+  }
+}
+
+internal struct Prompting_PromptingNIOClient: Prompting_PromptingClientProtocol {
+  internal var channel: GRPCChannel
+  internal var defaultCallOptions: CallOptions
+  internal var interceptors: Prompting_PromptingClientInterceptorFactoryProtocol?
+
+  /// Creates a client for the prompting.Prompting service.
+  ///
+  /// - Parameters:
+  ///   - channel: `GRPCChannel` to the service host.
+  ///   - defaultCallOptions: Options to use for each service call if the user doesn't provide them.
+  ///   - interceptors: A factory providing interceptors for each RPC.
+  internal init(
+    channel: GRPCChannel,
+    defaultCallOptions: CallOptions = CallOptions(),
+    interceptors: Prompting_PromptingClientInterceptorFactoryProtocol? = nil
+  ) {
+    self.channel = channel
+    self.defaultCallOptions = defaultCallOptions
+    self.interceptors = interceptors
+  }
+}
+
+/// Prompting allows clients to host and request prompting.
+@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
+internal protocol Prompting_PromptingAsyncClientProtocol: GRPCClient {
+  static var serviceDescriptor: GRPCServiceDescriptor { get }
+  var interceptors: Prompting_PromptingClientInterceptorFactoryProtocol? { get }
+
+  func makeHostCall(
+    callOptions: CallOptions?
+  ) -> GRPCAsyncBidirectionalStreamingCall<Prompting_HostRequest, Prompting_HostResponse>
+
+  func makePromptCall(
+    _ request: Prompting_PromptRequest,
+    callOptions: CallOptions?
+  ) -> GRPCAsyncUnaryCall<Prompting_PromptRequest, Prompting_PromptResponse>
+}
+
+@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
+extension Prompting_PromptingAsyncClientProtocol {
+  internal static var serviceDescriptor: GRPCServiceDescriptor {
+    return Prompting_PromptingClientMetadata.serviceDescriptor
+  }
+
+  internal var interceptors: Prompting_PromptingClientInterceptorFactoryProtocol? {
+    return nil
+  }
+
+  internal func makeHostCall(
+    callOptions: CallOptions? = nil
+  ) -> GRPCAsyncBidirectionalStreamingCall<Prompting_HostRequest, Prompting_HostResponse> {
+    return self.makeAsyncBidirectionalStreamingCall(
+      path: Prompting_PromptingClientMetadata.Methods.host.path,
+      callOptions: callOptions ?? self.defaultCallOptions,
+      interceptors: self.interceptors?.makeHostInterceptors() ?? []
+    )
+  }
+
+  internal func makePromptCall(
+    _ request: Prompting_PromptRequest,
+    callOptions: CallOptions? = nil
+  ) -> GRPCAsyncUnaryCall<Prompting_PromptRequest, Prompting_PromptResponse> {
+    return self.makeAsyncUnaryCall(
+      path: Prompting_PromptingClientMetadata.Methods.prompt.path,
+      request: request,
+      callOptions: callOptions ?? self.defaultCallOptions,
+      interceptors: self.interceptors?.makePromptInterceptors() ?? []
+    )
+  }
+}
+
+@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
+extension Prompting_PromptingAsyncClientProtocol {
+  internal func host<RequestStream>(
+    _ requests: RequestStream,
+    callOptions: CallOptions? = nil
+  ) -> GRPCAsyncResponseStream<Prompting_HostResponse> where RequestStream: Sequence, RequestStream.Element == Prompting_HostRequest {
+    return self.performAsyncBidirectionalStreamingCall(
+      path: Prompting_PromptingClientMetadata.Methods.host.path,
+      requests: requests,
+      callOptions: callOptions ?? self.defaultCallOptions,
+      interceptors: self.interceptors?.makeHostInterceptors() ?? []
+    )
+  }
+
+  internal func host<RequestStream>(
+    _ requests: RequestStream,
+    callOptions: CallOptions? = nil
+  ) -> GRPCAsyncResponseStream<Prompting_HostResponse> where RequestStream: AsyncSequence & Sendable, RequestStream.Element == Prompting_HostRequest {
+    return self.performAsyncBidirectionalStreamingCall(
+      path: Prompting_PromptingClientMetadata.Methods.host.path,
+      requests: requests,
+      callOptions: callOptions ?? self.defaultCallOptions,
+      interceptors: self.interceptors?.makeHostInterceptors() ?? []
+    )
+  }
+
+  internal func prompt(
+    _ request: Prompting_PromptRequest,
+    callOptions: CallOptions? = nil
+  ) async throws -> Prompting_PromptResponse {
+    return try await self.performAsyncUnaryCall(
+      path: Prompting_PromptingClientMetadata.Methods.prompt.path,
+      request: request,
+      callOptions: callOptions ?? self.defaultCallOptions,
+      interceptors: self.interceptors?.makePromptInterceptors() ?? []
+    )
+  }
+}
+
+@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
+internal struct Prompting_PromptingAsyncClient: Prompting_PromptingAsyncClientProtocol {
+  internal var channel: GRPCChannel
+  internal var defaultCallOptions: CallOptions
+  internal var interceptors: Prompting_PromptingClientInterceptorFactoryProtocol?
+
+  internal init(
+    channel: GRPCChannel,
+    defaultCallOptions: CallOptions = CallOptions(),
+    interceptors: Prompting_PromptingClientInterceptorFactoryProtocol? = nil
+  ) {
+    self.channel = channel
+    self.defaultCallOptions = defaultCallOptions
+    self.interceptors = interceptors
+  }
+}
+
+internal protocol Prompting_PromptingClientInterceptorFactoryProtocol: Sendable {
+
+  /// - Returns: Interceptors to use when invoking 'host'.
+  func makeHostInterceptors() -> [ClientInterceptor<Prompting_HostRequest, Prompting_HostResponse>]
+
+  /// - Returns: Interceptors to use when invoking 'prompt'.
+  func makePromptInterceptors() -> [ClientInterceptor<Prompting_PromptRequest, Prompting_PromptResponse>]
+}
+
+internal enum Prompting_PromptingClientMetadata {
+  internal static let serviceDescriptor = GRPCServiceDescriptor(
+    name: "Prompting",
+    fullName: "prompting.Prompting",
+    methods: [
+      Prompting_PromptingClientMetadata.Methods.host,
+      Prompting_PromptingClientMetadata.Methods.prompt,
+    ]
+  )
+
+  internal enum Methods {
+    internal static let host = GRPCMethodDescriptor(
+      name: "Host",
+      path: "/prompting.Prompting/Host",
+      type: GRPCCallType.bidirectionalStreaming
+    )
+
+    internal static let prompt = GRPCMethodDescriptor(
+      name: "Prompt",
+      path: "/prompting.Prompting/Prompt",
+      type: GRPCCallType.unary
+    )
+  }
+}
+
+/// Prompting allows clients to host and request prompting.
+///
+/// To build a server, implement a class that conforms to this protocol.
+internal protocol Prompting_PromptingProvider: CallHandlerProvider {
+  var interceptors: Prompting_PromptingServerInterceptorFactoryProtocol? { get }
+
+  /// Host allows clients to perform prompt hosting.
+  func host(context: StreamingResponseCallContext<Prompting_HostResponse>) -> EventLoopFuture<(StreamEvent<Prompting_HostRequest>) -> Void>
+
+  /// Prompt performs prompting using a specific prompter.
+  func prompt(request: Prompting_PromptRequest, context: StatusOnlyCallContext) -> EventLoopFuture<Prompting_PromptResponse>
+}
+
+extension Prompting_PromptingProvider {
+  internal var serviceName: Substring {
+    return Prompting_PromptingServerMetadata.serviceDescriptor.fullName[...]
+  }
+
+  /// Determines, calls and returns the appropriate request handler, depending on the request's method.
+  /// Returns nil for methods not handled by this service.
+  internal func handle(
+    method name: Substring,
+    context: CallHandlerContext
+  ) -> GRPCServerHandlerProtocol? {
+    switch name {
+    case "Host":
+      return BidirectionalStreamingServerHandler(
+        context: context,
+        requestDeserializer: ProtobufDeserializer<Prompting_HostRequest>(),
+        responseSerializer: ProtobufSerializer<Prompting_HostResponse>(),
+        interceptors: self.interceptors?.makeHostInterceptors() ?? [],
+        observerFactory: self.host(context:)
+      )
+
+    case "Prompt":
+      return UnaryServerHandler(
+        context: context,
+        requestDeserializer: ProtobufDeserializer<Prompting_PromptRequest>(),
+        responseSerializer: ProtobufSerializer<Prompting_PromptResponse>(),
+        interceptors: self.interceptors?.makePromptInterceptors() ?? [],
+        userFunction: self.prompt(request:context:)
+      )
+
+    default:
+      return nil
+    }
+  }
+}
+
+/// Prompting allows clients to host and request prompting.
+///
+/// To implement a server, implement an object which conforms to this protocol.
+@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
+internal protocol Prompting_PromptingAsyncProvider: CallHandlerProvider, Sendable {
+  static var serviceDescriptor: GRPCServiceDescriptor { get }
+  var interceptors: Prompting_PromptingServerInterceptorFactoryProtocol? { get }
+
+  /// Host allows clients to perform prompt hosting.
+  func host(
+    requestStream: GRPCAsyncRequestStream<Prompting_HostRequest>,
+    responseStream: GRPCAsyncResponseStreamWriter<Prompting_HostResponse>,
+    context: GRPCAsyncServerCallContext
+  ) async throws
+
+  /// Prompt performs prompting using a specific prompter.
+  func prompt(
+    request: Prompting_PromptRequest,
+    context: GRPCAsyncServerCallContext
+  ) async throws -> Prompting_PromptResponse
+}
+
+@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
+extension Prompting_PromptingAsyncProvider {
+  internal static var serviceDescriptor: GRPCServiceDescriptor {
+    return Prompting_PromptingServerMetadata.serviceDescriptor
+  }
+
+  internal var serviceName: Substring {
+    return Prompting_PromptingServerMetadata.serviceDescriptor.fullName[...]
+  }
+
+  internal var interceptors: Prompting_PromptingServerInterceptorFactoryProtocol? {
+    return nil
+  }
+
+  internal func handle(
+    method name: Substring,
+    context: CallHandlerContext
+  ) -> GRPCServerHandlerProtocol? {
+    switch name {
+    case "Host":
+      return GRPCAsyncServerHandler(
+        context: context,
+        requestDeserializer: ProtobufDeserializer<Prompting_HostRequest>(),
+        responseSerializer: ProtobufSerializer<Prompting_HostResponse>(),
+        interceptors: self.interceptors?.makeHostInterceptors() ?? [],
+        wrapping: { try await self.host(requestStream: $0, responseStream: $1, context: $2) }
+      )
+
+    case "Prompt":
+      return GRPCAsyncServerHandler(
+        context: context,
+        requestDeserializer: ProtobufDeserializer<Prompting_PromptRequest>(),
+        responseSerializer: ProtobufSerializer<Prompting_PromptResponse>(),
+        interceptors: self.interceptors?.makePromptInterceptors() ?? [],
+        wrapping: { try await self.prompt(request: $0, context: $1) }
+      )
+
+    default:
+      return nil
+    }
+  }
+}
+
+internal protocol Prompting_PromptingServerInterceptorFactoryProtocol: Sendable {
+
+  /// - Returns: Interceptors to use when handling 'host'.
+  ///   Defaults to calling `self.makeInterceptors()`.
+  func makeHostInterceptors() -> [ServerInterceptor<Prompting_HostRequest, Prompting_HostResponse>]
+
+  /// - Returns: Interceptors to use when handling 'prompt'.
+  ///   Defaults to calling `self.makeInterceptors()`.
+  func makePromptInterceptors() -> [ServerInterceptor<Prompting_PromptRequest, Prompting_PromptResponse>]
+}
+
+internal enum Prompting_PromptingServerMetadata {
+  internal static let serviceDescriptor = GRPCServiceDescriptor(
+    name: "Prompting",
+    fullName: "prompting.Prompting",
+    methods: [
+      Prompting_PromptingServerMetadata.Methods.host,
+      Prompting_PromptingServerMetadata.Methods.prompt,
+    ]
+  )
+
+  internal enum Methods {
+    internal static let host = GRPCMethodDescriptor(
+      name: "Host",
+      path: "/prompting.Prompting/Host",
+      type: GRPCCallType.bidirectionalStreaming
+    )
+
+    internal static let prompt = GRPCMethodDescriptor(
+      name: "Prompt",
+      path: "/prompting.Prompting/Prompt",
+      type: GRPCCallType.unary
+    )
+  }
+}
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.pb.swift
new file mode 100644
index 00000000..74afe922
--- /dev/null
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.pb.swift
@@ -0,0 +1,279 @@
+// DO NOT EDIT.
+// swift-format-ignore-file
+// swiftlint:disable all
+//
+// Generated by the Swift generator plugin for the protocol buffer compiler.
+// Source: service_prompting_prompting.proto
+//
+// For information on using the generated types, please see the documentation:
+//   https://github.com/apple/swift-protobuf/
+
+//
+// This file was taken from
+// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/service/prompting/prompting.proto
+//
+// MIT License
+// 
+// Copyright (c) 2016-present Docker, Inc.
+// 
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+// 
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+// 
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+import SwiftProtobuf
+
+// If the compiler emits an error on this type, it is because this file
+// was generated by a version of the `protoc` Swift plug-in that is
+// incompatible with the version of SwiftProtobuf to which you are linking.
+// Please ensure that you are building against the same version of the API
+// that was used to generate this file.
+fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck {
+  struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {}
+  typealias Version = _2
+}
+
+/// HostRequest encodes either an initial request to perform prompt hosting or a
+/// follow-up response to a message or prompt.
+struct Prompting_HostRequest: Sendable {
+  // SwiftProtobuf.Message conformance is added in an extension below. See the
+  // `Message` and `Message+*Additions` files in the SwiftProtobuf library for
+  // methods supported on all messages.
+
+  /// AllowPrompts indicates whether or not the hoster will allow prompts. If
+  /// not, it will only receive message requests. This field may only be set on
+  /// the initial request.
+  var allowPrompts: Bool = false
+
+  /// Response is the prompt response, if any. On the initial request, this
+  /// must be an empty string. When responding to a prompt, it may be any
+  /// value. When responding to a message, it must be an empty string.
+  var response: String = String()
+
+  var unknownFields = SwiftProtobuf.UnknownStorage()
+
+  init() {}
+}
+
+/// HostResponse encodes either an initial response to perform prompt hosting or
+/// a follow-up request for messaging or prompting.
+struct Prompting_HostResponse: Sendable {
+  // SwiftProtobuf.Message conformance is added in an extension below. See the
+  // `Message` and `Message+*Additions` files in the SwiftProtobuf library for
+  // methods supported on all messages.
+
+  /// Identifier is the prompter identifier. It is only set in the initial
+  /// response sent after the initial request.
+  var identifier: String = String()
+
+  /// IsPrompt indicates if the response is requesting a prompt (as opposed to
+  /// simple message display).
+  var isPrompt: Bool = false
+
+  /// Message is the message associated with the prompt or message.
+  var message: String = String()
+
+  var unknownFields = SwiftProtobuf.UnknownStorage()
+
+  init() {}
+}
+
+/// PromptRequest encodes a request for prompting by a specific prompter.
+struct Prompting_PromptRequest: Sendable {
+  // SwiftProtobuf.Message conformance is added in an extension below. See the
+  // `Message` and `Message+*Additions` files in the SwiftProtobuf library for
+  // methods supported on all messages.
+
+  /// Prompter is the prompter identifier.
+  var prompter: String = String()
+
+  /// Prompt is the prompt to present.
+  var prompt: String = String()
+
+  var unknownFields = SwiftProtobuf.UnknownStorage()
+
+  init() {}
+}
+
+/// PromptResponse encodes the response from a prompter.
+struct Prompting_PromptResponse: Sendable {
+  // SwiftProtobuf.Message conformance is added in an extension below. See the
+  // `Message` and `Message+*Additions` files in the SwiftProtobuf library for
+  // methods supported on all messages.
+
+  /// Response is the response returned by the prompter.
+  var response: String = String()
+
+  var unknownFields = SwiftProtobuf.UnknownStorage()
+
+  init() {}
+}
+
+// MARK: - Code below here is support for the SwiftProtobuf runtime.
+
+fileprivate let _protobuf_package = "prompting"
+
+extension Prompting_HostRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
+  static let protoMessageName: String = _protobuf_package + ".HostRequest"
+  static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
+    1: .same(proto: "allowPrompts"),
+    2: .same(proto: "response"),
+  ]
+
+  mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
+    while let fieldNumber = try decoder.nextFieldNumber() {
+      // The use of inline closures is to circumvent an issue where the compiler
+      // allocates stack space for every case branch when no optimizations are
+      // enabled. https://github.com/apple/swift-protobuf/issues/1034
+      switch fieldNumber {
+      case 1: try { try decoder.decodeSingularBoolField(value: &self.allowPrompts) }()
+      case 2: try { try decoder.decodeSingularStringField(value: &self.response) }()
+      default: break
+      }
+    }
+  }
+
+  func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
+    if self.allowPrompts != false {
+      try visitor.visitSingularBoolField(value: self.allowPrompts, fieldNumber: 1)
+    }
+    if !self.response.isEmpty {
+      try visitor.visitSingularStringField(value: self.response, fieldNumber: 2)
+    }
+    try unknownFields.traverse(visitor: &visitor)
+  }
+
+  static func ==(lhs: Prompting_HostRequest, rhs: Prompting_HostRequest) -> Bool {
+    if lhs.allowPrompts != rhs.allowPrompts {return false}
+    if lhs.response != rhs.response {return false}
+    if lhs.unknownFields != rhs.unknownFields {return false}
+    return true
+  }
+}
+
+extension Prompting_HostResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
+  static let protoMessageName: String = _protobuf_package + ".HostResponse"
+  static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
+    1: .same(proto: "identifier"),
+    2: .same(proto: "isPrompt"),
+    3: .same(proto: "message"),
+  ]
+
+  mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
+    while let fieldNumber = try decoder.nextFieldNumber() {
+      // The use of inline closures is to circumvent an issue where the compiler
+      // allocates stack space for every case branch when no optimizations are
+      // enabled. https://github.com/apple/swift-protobuf/issues/1034
+      switch fieldNumber {
+      case 1: try { try decoder.decodeSingularStringField(value: &self.identifier) }()
+      case 2: try { try decoder.decodeSingularBoolField(value: &self.isPrompt) }()
+      case 3: try { try decoder.decodeSingularStringField(value: &self.message) }()
+      default: break
+      }
+    }
+  }
+
+  func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
+    if !self.identifier.isEmpty {
+      try visitor.visitSingularStringField(value: self.identifier, fieldNumber: 1)
+    }
+    if self.isPrompt != false {
+      try visitor.visitSingularBoolField(value: self.isPrompt, fieldNumber: 2)
+    }
+    if !self.message.isEmpty {
+      try visitor.visitSingularStringField(value: self.message, fieldNumber: 3)
+    }
+    try unknownFields.traverse(visitor: &visitor)
+  }
+
+  static func ==(lhs: Prompting_HostResponse, rhs: Prompting_HostResponse) -> Bool {
+    if lhs.identifier != rhs.identifier {return false}
+    if lhs.isPrompt != rhs.isPrompt {return false}
+    if lhs.message != rhs.message {return false}
+    if lhs.unknownFields != rhs.unknownFields {return false}
+    return true
+  }
+}
+
+extension Prompting_PromptRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
+  static let protoMessageName: String = _protobuf_package + ".PromptRequest"
+  static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
+    1: .same(proto: "prompter"),
+    2: .same(proto: "prompt"),
+  ]
+
+  mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
+    while let fieldNumber = try decoder.nextFieldNumber() {
+      // The use of inline closures is to circumvent an issue where the compiler
+      // allocates stack space for every case branch when no optimizations are
+      // enabled. https://github.com/apple/swift-protobuf/issues/1034
+      switch fieldNumber {
+      case 1: try { try decoder.decodeSingularStringField(value: &self.prompter) }()
+      case 2: try { try decoder.decodeSingularStringField(value: &self.prompt) }()
+      default: break
+      }
+    }
+  }
+
+  func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
+    if !self.prompter.isEmpty {
+      try visitor.visitSingularStringField(value: self.prompter, fieldNumber: 1)
+    }
+    if !self.prompt.isEmpty {
+      try visitor.visitSingularStringField(value: self.prompt, fieldNumber: 2)
+    }
+    try unknownFields.traverse(visitor: &visitor)
+  }
+
+  static func ==(lhs: Prompting_PromptRequest, rhs: Prompting_PromptRequest) -> Bool {
+    if lhs.prompter != rhs.prompter {return false}
+    if lhs.prompt != rhs.prompt {return false}
+    if lhs.unknownFields != rhs.unknownFields {return false}
+    return true
+  }
+}
+
+extension Prompting_PromptResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
+  static let protoMessageName: String = _protobuf_package + ".PromptResponse"
+  static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
+    1: .same(proto: "response"),
+  ]
+
+  mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
+    while let fieldNumber = try decoder.nextFieldNumber() {
+      // The use of inline closures is to circumvent an issue where the compiler
+      // allocates stack space for every case branch when no optimizations are
+      // enabled. https://github.com/apple/swift-protobuf/issues/1034
+      switch fieldNumber {
+      case 1: try { try decoder.decodeSingularStringField(value: &self.response) }()
+      default: break
+      }
+    }
+  }
+
+  func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
+    if !self.response.isEmpty {
+      try visitor.visitSingularStringField(value: self.response, fieldNumber: 1)
+    }
+    try unknownFields.traverse(visitor: &visitor)
+  }
+
+  static func ==(lhs: Prompting_PromptResponse, rhs: Prompting_PromptResponse) -> Bool {
+    if lhs.response != rhs.response {return false}
+    if lhs.unknownFields != rhs.unknownFields {return false}
+    return true
+  }
+}
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.proto
new file mode 100644
index 00000000..337a1544
--- /dev/null
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.proto
@@ -0,0 +1,80 @@
+/*
+ * This file was taken from
+ * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/service/prompting/prompting.proto
+ *
+ * MIT License
+ * 
+ * Copyright (c) 2016-present Docker, Inc.
+ * 
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ * 
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ * 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+syntax = "proto3";
+
+package prompting;
+
+option go_package = "github.com/mutagen-io/mutagen/pkg/service/prompting";
+
+// HostRequest encodes either an initial request to perform prompt hosting or a
+// follow-up response to a message or prompt.
+message HostRequest {
+    // AllowPrompts indicates whether or not the hoster will allow prompts. If
+    // not, it will only receive message requests. This field may only be set on
+    // the initial request.
+    bool allowPrompts = 1;
+    // Response is the prompt response, if any. On the initial request, this
+    // must be an empty string. When responding to a prompt, it may be any
+    // value. When responding to a message, it must be an empty string.
+    string response = 2;
+}
+
+// HostResponse encodes either an initial response to perform prompt hosting or
+// a follow-up request for messaging or prompting.
+message HostResponse {
+    // Identifier is the prompter identifier. It is only set in the initial
+    // response sent after the initial request.
+    string identifier = 1;
+    // IsPrompt indicates if the response is requesting a prompt (as opposed to
+    // simple message display).
+    bool isPrompt = 2;
+    // Message is the message associated with the prompt or message.
+    string message = 3;
+}
+
+// PromptRequest encodes a request for prompting by a specific prompter.
+message PromptRequest {
+    // Prompter is the prompter identifier.
+    string prompter = 1;
+    // Prompt is the prompt to present.
+    string prompt = 2;
+}
+
+// PromptResponse encodes the response from a prompter.
+message PromptResponse {
+    // Response is the response returned by the prompter.
+    string response = 1;
+}
+
+// Prompting allows clients to host and request prompting.
+service Prompting {
+    // Host allows clients to perform prompt hosting.
+    rpc Host(stream HostRequest) returns (stream HostResponse) {}
+    // Prompt performs prompting using a specific prompter.
+    rpc Prompt(PromptRequest) returns (PromptResponse) {}
+}
diff --git a/scripts/mutagen-proto.sh b/scripts/mutagen-proto.sh
index 4fc6cf67..fb01413b 100755
--- a/scripts/mutagen-proto.sh
+++ b/scripts/mutagen-proto.sh
@@ -4,9 +4,9 @@
 # It is very similar to `Update-Proto.ps1` on `coder/coder-desktop-windows`.
 # It's very unlikely that we'll use this script regularly.
 #
-# Unlike the Go compiler, the Swift compiler does not support multiple files 
-# with the same name in different directories. 
-# To handle this, this script flattens the directory structure of the proto 
+# Unlike the Go compiler, the Swift compiler does not support multiple files
+# with the same name in different directories.
+# To handle this, this script flattens the directory structure of the proto
 # files into the filename, i.e. `service/synchronization/synchronization.proto`
 # becomes `service_synchronization_synchronization.proto`.
 # It also updates the proto imports to use these paths.
@@ -24,7 +24,7 @@ mutagen_tag="$1"
 repo="mutagen-io/mutagen"
 proto_prefix="pkg"
 # Right now, we only care about the synchronization and daemon management gRPC
-entry_files=("service/synchronization/synchronization.proto" "service/daemon/daemon.proto")
+entry_files=("service/synchronization/synchronization.proto" "service/daemon/daemon.proto" "service/prompting/prompting.proto")
 
 out_folder="Coder-Desktop/VPNLib/FileSync/MutagenSDK"
 
@@ -33,7 +33,7 @@ if [ -d "$clone_dir" ]; then
     echo "Found existing mutagen repo at $clone_dir, checking out $mutagen_tag..."
     pushd "$clone_dir" > /dev/null
     git clean -fdx
-    
+
     current_tag=$(git name-rev --name-only HEAD)
     if [ "$current_tag" != "tags/$mutagen_tag" ]; then
         git fetch --all
@@ -62,27 +62,27 @@ add_file() {
     local proto_path="${filepath#"$clone_dir"/"$proto_prefix"/}"
     local flat_name
     flat_name=$(echo "$proto_path" | sed 's/\//_/g')
-    
+
     # Skip if already processed
     if [[ -n "${file_map[$proto_path]:-}" ]]; then
         return
     fi
-    
+
     echo "Adding $proto_path -> $flat_name"
     file_map[$proto_path]=$flat_name
     file_paths+=("$filepath")
-    
+
     # Process imports
     while IFS= read -r line; do
         if [[ $line =~ ^import\ \"(.+)\" ]]; then
             import_path="${BASH_REMATCH[1]}"
-            
+
             # Ignore google imports, as they're not vendored
             if [[ $import_path =~ ^google/ ]]; then
                 echo "Skipping $import_path"
                 continue
             fi
-            
+
             import_file_path="$clone_dir/$proto_prefix/$import_path"
             if [ -f "$import_file_path" ]; then
                 add_file "$import_file_path"
@@ -109,24 +109,24 @@ for file_path in "${file_paths[@]}"; do
     proto_path="${file_path#"$clone_dir"/"$proto_prefix"/}"
     flat_name="${file_map[$proto_path]}"
     dst_path="$out_folder/$flat_name"
-    
+
     cp -f "$file_path" "$dst_path"
-    
+
     file_header="/*\n * This file was taken from\n * https://github.com/$repo/tree/$mutagen_tag/$proto_prefix/$proto_path\n *\n$license_header\n */\n\n"
     content=$(cat "$dst_path")
     echo -e "$file_header$content" > "$dst_path"
-    
+
     tmp_file=$(mktemp)
     while IFS= read -r line; do
         if [[ $line =~ ^import\ \"(.+)\" ]]; then
             import_path="${BASH_REMATCH[1]}"
-            
+
             # Retain google imports
             if [[ $import_path =~ ^google/ ]]; then
                 echo "$line" >> "$tmp_file"
                 continue
             fi
-            
+
             # Convert import path to flattened format
             flat_import=$(echo "$import_path" | sed 's/\//_/g')
             echo "import \"$flat_import\";" >> "$tmp_file"
@@ -135,7 +135,7 @@ for file_path in "${file_paths[@]}"; do
         fi
     done < "$dst_path"
     mv "$tmp_file" "$dst_path"
-    
+
     echo "Processed $proto_path -> $flat_name"
 done