From bcfd1c55216a6f3d5b80e82d0b1df3feba0fdb62 Mon Sep 17 00:00:00 2001 From: Samuel Volin Date: Fri, 4 Sep 2026 14:47:59 +0000 Subject: [PATCH] fix: send a User-Agent on all deployment requests --- .../Coder-DesktopHelper/Manager.swift | 5 +- Coder-Desktop/CoderSDK/Client.swift | 25 +++- Coder-Desktop/CoderSDK/HTTP.swift | 1 + Coder-Desktop/CoderSDK/UserAgent.swift | 48 ++++++++ .../CoderSDKTests/UserAgentTests.swift | 109 ++++++++++++++++++ Coder-Desktop/VPNLib/Download.swift | 4 + 6 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 Coder-Desktop/CoderSDK/UserAgent.swift create mode 100644 Coder-Desktop/CoderSDKTests/UserAgentTests.swift diff --git a/Coder-Desktop/Coder-DesktopHelper/Manager.swift b/Coder-Desktop/Coder-DesktopHelper/Manager.swift index a83b3d51..0bcb7c2c 100644 --- a/Coder-Desktop/Coder-DesktopHelper/Manager.swift +++ b/Coder-Desktop/Coder-DesktopHelper/Manager.swift @@ -41,7 +41,7 @@ actor Manager { "Failed to create directories for binary destination (\(dest)): \(error.localizedDescription)" ) } - let client = Client(url: cfg.serverUrl, headers: cfg.literalHeaders) + let client = Client(url: cfg.serverUrl, headers: cfg.literalHeaders, component: .helper) let buildInfo: BuildInfoResponse do { buildInfo = try await client.buildInfo() @@ -68,7 +68,8 @@ actor Manager { src: binaryPath, dest: dest, urlSession: URLSession(configuration: sessionConfig), - headers: cfg.literalHeaders + headers: cfg.literalHeaders, + component: .helper ) { progress in pushProgress(stage: .downloading, downloadProgress: progress) } diff --git a/Coder-Desktop/CoderSDK/Client.swift b/Coder-Desktop/CoderSDK/Client.swift index 991cdf60..233c2aea 100644 --- a/Coder-Desktop/CoderSDK/Client.swift +++ b/Coder-Desktop/CoderSDK/Client.swift @@ -4,11 +4,18 @@ public struct Client: Sendable { public let url: URL public var token: String? public var headers: [HTTPHeader] - - public init(url: URL, token: String? = nil, headers: [HTTPHeader] = []) { + public var component: CoderComponent + + public init( + url: URL, + token: String? = nil, + headers: [HTTPHeader] = [], + component: CoderComponent = .app + ) { self.url = url self.token = token self.headers = headers + self.component = component } func request( @@ -25,6 +32,7 @@ public struct Client: Sendable { path: path, method: method, headers: headers, + component: component, body: body ) } @@ -41,7 +49,8 @@ public struct Client: Sendable { baseURL: url, path: path, method: method, - headers: headers + headers: headers, + component: component ) } } @@ -115,11 +124,13 @@ func doRequest( path: String, method: HTTPMethod, headers: [HTTPHeader] = [], + component: CoderComponent = .app, body: Data? = nil ) async throws(SDKError) -> HTTPResponse { let url = baseURL.appendingPathComponent(path) var req = URLRequest(url: url) req.httpMethod = method.rawValue + req.setCoderUserAgent(component, unlessIn: headers) for header in headers { req.addValue(header.value, forHTTPHeaderField: header.name) } @@ -142,6 +153,7 @@ func request( path: String, method: HTTPMethod, headers: [HTTPHeader] = [], + component: CoderComponent = .app, body: some Encodable & Sendable ) async throws(SDKError) -> HTTPResponse { let encodedBody: Data @@ -155,6 +167,7 @@ func request( path: path, method: method, headers: headers, + component: component, body: encodedBody ) } @@ -163,13 +176,15 @@ func request( baseURL: URL, path: String, method: HTTPMethod, - headers: [HTTPHeader] = [] + headers: [HTTPHeader] = [], + component: CoderComponent = .app ) async throws(SDKError) -> HTTPResponse { try await doRequest( baseURL: baseURL, path: path, method: method, - headers: headers + headers: headers, + component: component ) } diff --git a/Coder-Desktop/CoderSDK/HTTP.swift b/Coder-Desktop/CoderSDK/HTTP.swift index d984c87f..87691c74 100644 --- a/Coder-Desktop/CoderSDK/HTTP.swift +++ b/Coder-Desktop/CoderSDK/HTTP.swift @@ -25,4 +25,5 @@ enum HTTPMethod: String, Equatable, Hashable, Sendable { enum Headers { static let sessionToken = "Coder-Session-Token" + static let userAgent = "User-Agent" } diff --git a/Coder-Desktop/CoderSDK/UserAgent.swift b/Coder-Desktop/CoderSDK/UserAgent.swift new file mode 100644 index 00000000..d10fe65c --- /dev/null +++ b/Coder-Desktop/CoderSDK/UserAgent.swift @@ -0,0 +1,48 @@ +import Foundation + +/// Identifies which Coder Desktop process is making a request. +public enum CoderComponent: String, Sendable { + case app = "coder-desktop" + case helper = "coder-desktop-core" +} + +/// Go's `GOARCH` spelling, so a single pattern matches the CLI, the vpn-daemon and Desktop client. +enum GoArch: String, Sendable { + case arm64 + case amd64 +} + +enum UserAgentDefaults { + static let goos = "darwin" + static let unknownVersion = "0.0.0" + + #if arch(arm64) + static let arch: GoArch = .arm64 + #elseif arch(x86_64) + static let arch: GoArch = .amd64 + #endif + + static var version: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? unknownVersion + } +} + +/// Formats a `User-Agent` as `/ (/)`. +func userAgent( + component: CoderComponent, + version: String = UserAgentDefaults.version, + arch: GoArch = UserAgentDefaults.arch +) -> String { + "\(component.rawValue)/\(version) (\(UserAgentDefaults.goos)/\(arch.rawValue))" +} + +public extension URLRequest { + /// Sets the Coder `User-Agent`, unless `headers` already carries one. + mutating func setCoderUserAgent(_ component: CoderComponent, unlessIn headers: [HTTPHeader]) { + let callerSuppliedUA = headers.contains { + $0.name.caseInsensitiveCompare(Headers.userAgent) == .orderedSame + } + guard !callerSuppliedUA else { return } + setValue(userAgent(component: component), forHTTPHeaderField: Headers.userAgent) + } +} diff --git a/Coder-Desktop/CoderSDKTests/UserAgentTests.swift b/Coder-Desktop/CoderSDKTests/UserAgentTests.swift new file mode 100644 index 00000000..7e9f46ed --- /dev/null +++ b/Coder-Desktop/CoderSDKTests/UserAgentTests.swift @@ -0,0 +1,109 @@ +@testable import CoderSDK +import Foundation +import Mocker +import Testing + +@Suite(.timeLimit(.minutes(1))) +struct UserAgentTests { + // The pattern operators are expected to match against every Coder client. + static let grammar = #"^coder-desktop(-core)?/[0-9]+\.[0-9]+\.[0-9]+ \(darwin/(amd64|arm64)\)$"# + + static let stubVersion = "0.8.4" + static let stubServerVersion = "v2.18.2" + + @Test + func appToken() { + #expect( + userAgent(component: .app, version: Self.stubVersion, arch: .arm64) + == "coder-desktop/\(Self.stubVersion) (darwin/arm64)" + ) + } + + @Test + func helperToken() { + #expect( + userAgent(component: .helper, version: Self.stubVersion, arch: .arm64) + == "coder-desktop-core/\(Self.stubVersion) (darwin/arm64)" + ) + } + + // The CLI and the vpn-daemon report `amd64`, so an Intel build must too. + @Test + func intelReportsGoArchNotSwiftArch() { + let ua = userAgent(component: .app, version: Self.stubVersion, arch: .amd64) + #expect(ua == "coder-desktop/\(Self.stubVersion) (darwin/amd64)") + #expect(!ua.contains("x86_64")) + } + + @Test + func matchesGrammar() throws { + let regex = try Regex(Self.grammar) + for component in [CoderComponent.app, .helper] { + for arch in [GoArch.arm64, .amd64] { + let ua = userAgent(component: component, version: Self.stubVersion, arch: arch) + #expect(ua.contains(regex)) + } + // The version the running bundle actually reports. + #expect(userAgent(component: component).contains(regex)) + } + } + + @Test + func missingVersionFallsBackToValidGrammar() throws { + let regex = try Regex(Self.grammar) + let ua = userAgent( + component: .app, + version: UserAgentDefaults.unknownVersion, + arch: .arm64 + ) + #expect(ua == "coder-desktop/\(UserAgentDefaults.unknownVersion) (darwin/arm64)") + #expect(ua.contains(regex)) + } + + @Test + func setOnRequest() async throws { + // A distinct host per test: Mocker's registry is process-global and + // suites run in parallel. + let url = URL(string: "https://ua-default.example.com")! + let client = Client(url: url, component: .helper) + let sentUA = try await capturedUserAgent(client: client, url: url) + + #expect(try sentUA.contains(Regex(Self.grammar))) + #expect(sentUA.hasPrefix("coder-desktop-core/")) + } + + // A user-configured literal header must replace ours outright. Caller headers + // are applied with `addValue`, which would otherwise comma-join the two. + @Test + func callerSuppliedUserAgentWins() async throws { + let url = URL(string: "https://ua-override.example.com")! + let custom = "my-own-agent/1.2.3" + let client = Client( + url: url, + headers: [.init(name: "user-agent", value: custom)], + component: .helper + ) + let sentUA = try await capturedUserAgent(client: client, url: url) + + #expect(sentUA == custom) + #expect(!sentUA.contains("coder-desktop")) + } + + /// Runs `buildInfo()` against a mock and returns the `User-Agent` it sent. + private func capturedUserAgent(client: Client, url: URL) async throws -> String { + var mock = try Mock( + url: url.appending(path: "api/v2/buildinfo"), + contentType: .json, + statusCode: 200, + data: [.get: CoderSDK.encoder.encode(BuildInfoResponse(version: Self.stubServerVersion))] + ) + var sentUA: String? + mock.onRequestHandler = OnRequestHandler { req in + sentUA = req.value(forHTTPHeaderField: Headers.userAgent) + } + mock.register() + + _ = try await client.buildInfo() + return try #require(sentUA) + } +} diff --git a/Coder-Desktop/VPNLib/Download.swift b/Coder-Desktop/VPNLib/Download.swift index 3441b982..680bb3bb 100644 --- a/Coder-Desktop/VPNLib/Download.swift +++ b/Coder-Desktop/VPNLib/Download.swift @@ -7,6 +7,7 @@ public func download( dest: URL, urlSession: URLSession, headers: [HTTPHeader] = [], + component: CoderComponent = .app, progressUpdates: (@Sendable (DownloadProgress) -> Void)? = nil ) async throws(DownloadError) { try await DownloadManager().download( @@ -14,6 +15,7 @@ public func download( dest: dest, urlSession: urlSession, headers: headers, + component: component, progressUpdates: progressUpdates.flatMap { throttle(interval: .milliseconds(10), $0) } ) } @@ -58,9 +60,11 @@ private final class DownloadManager: NSObject, @unchecked Sendable { dest: URL, urlSession: URLSession, headers: [HTTPHeader] = [], + component: CoderComponent = .app, progressUpdates: (@Sendable (DownloadProgress) -> Void)? ) async throws(DownloadError) { var req = URLRequest(url: src) + req.setCoderUserAgent(component, unlessIn: headers) for header in headers { req.addValue(header.value, forHTTPHeaderField: header.name) }