diff --git a/clients/ios/Views/Settings/QRPairingSheet.swift b/clients/ios/Views/Settings/QRPairingSheet.swift index 8e01e1304fb..f3dde7074c1 100644 --- a/clients/ios/Views/Settings/QRPairingSheet.swift +++ b/clients/ios/Views/Settings/QRPairingSheet.swift @@ -361,11 +361,13 @@ struct QRPairingSheet: View { return } let localLanUrl = response["localLanUrl"] as? String + let featureFlagToken = response["featureFlagToken"] as? String savePairingConfig( bearerToken: bearerToken, gatewayUrl: gatewayUrl, hostId: payload.hostId, - localLanUrl: localLanUrl + localLanUrl: localLanUrl, + featureFlagToken: featureFlagToken ) connectToMac() @@ -433,11 +435,13 @@ struct QRPairingSheet: View { return } let localLanUrl = json["localLanUrl"] as? String + let featureFlagToken = json["featureFlagToken"] as? String savePairingConfig( bearerToken: bearerToken, gatewayUrl: gatewayUrl, hostId: payload.hostId, - localLanUrl: localLanUrl + localLanUrl: localLanUrl, + featureFlagToken: featureFlagToken ) connectToMac() @@ -462,12 +466,19 @@ struct QRPairingSheet: View { // MARK: - Config Persistence - private func savePairingConfig(bearerToken: String, gatewayUrl: String, hostId: String, localLanUrl: String?) { + private func savePairingConfig(bearerToken: String, gatewayUrl: String, hostId: String, localLanUrl: String?, featureFlagToken: String? = nil) { UserDefaults.standard.set(gatewayUrl, forKey: UserDefaultsKeys.gatewayBaseURL) _ = APIKeyManager.shared.setAPIKey(bearerToken, provider: "runtime-bearer-token") if !hostId.isEmpty { UserDefaults.standard.set(hostId, forKey: "gateway_host_id") } + if let ffToken = featureFlagToken, !ffToken.isEmpty { + _ = APIKeyManager.shared.setAPIKey(ffToken, provider: "feature-flag-token") + } else { + // Clear any stale token from a previous pairing so we don't + // authenticate with an invalid credential on re-pair. + _ = APIKeyManager.shared.deleteAPIKey(provider: "feature-flag-token") + } // Generate conversation key if missing if UserDefaults.standard.string(forKey: UserDefaultsKeys.conversationKey)?.isEmpty != false { diff --git a/clients/shared/IPC/DaemonClient.swift b/clients/shared/IPC/DaemonClient.swift index 0db9cef8a7b..fb55953b52a 100644 --- a/clients/shared/IPC/DaemonClient.swift +++ b/clients/shared/IPC/DaemonClient.swift @@ -80,6 +80,12 @@ public func resolveHttpTokenPath(environment: [String: String]? = nil) -> String return resolveVellumDir(environment: environment) + "/http-token" } +/// Resolve the feature-flag bearer token path. +/// Uses BASE_DATA_DIR when set to match daemon root resolution. +public func resolveFeatureFlagTokenPath(environment: [String: String]? = nil) -> String { + return resolveVellumDir(environment: environment) + "/feature-flag-token" +} + /// Resolve the daemon PID file path, honoring `BASE_DATA_DIR`. public func resolvePidPath(environment: [String: String]? = nil) -> String { return resolveVellumDir(environment: environment) + "/vellum.pid" @@ -104,6 +110,25 @@ public func readHttpToken(environment: [String: String]? = nil) -> String? { return token } +/// Read the feature-flag bearer token from disk. +/// Used to authenticate PATCH /v1/feature-flags/:flagKey requests. +public func readFeatureFlagToken(environment: [String: String]? = nil) -> String? { + let tokenPath = resolveFeatureFlagTokenPath(environment: environment) + let data: Data + do { + data = try Data(contentsOf: URL(fileURLWithPath: tokenPath)) + } catch { + log.error("Failed to read feature-flag token from \(tokenPath, privacy: .private): \(error)") + return nil + } + guard let token = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty else { + return nil + } + return token +} + /// Protocol for daemon client communication, enabling dependency injection and testing. @MainActor public protocol DaemonClientProtocol { @@ -1506,4 +1531,69 @@ public final class DaemonClient: ObservableObject, DaemonClientProtocol { try send(ApprovedDevicesClearMessage()) } + // MARK: - Feature Flags + + /// Toggle a feature flag via the gateway's PATCH /v1/feature-flags/:flagKey endpoint. + /// Uses the dedicated feature-flag token (not the runtime bearer token) for auth. + /// + /// On macOS, always calls the local gateway directly (port 7830) because + /// `httpTransport` may point at the runtime HTTP server (port 7821) when + /// `localHttpEnabled` is active — the runtime doesn't serve feature-flag routes. + /// On iOS, delegates to `httpTransport` which targets the remote gateway. + public func setFeatureFlag(key: String, enabled: Bool) async throws { + guard let token = config.featureFlagToken, !token.isEmpty else { + throw FeatureFlagError.missingToken + } + + #if os(macOS) + // Always call the gateway directly on macOS. httpTransport may target the + // runtime (localHttpEnabled), which doesn't have the feature-flag route. + let gatewayPort = ProcessInfo.processInfo.environment["GATEWAY_PORT"] + .flatMap(Int.init) ?? 7830 + let baseURL = "http://127.0.0.1:\(gatewayPort)" + let encoded = key.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? key + guard let url = URL(string: "\(baseURL)/v1/feature-flags/\(encoded)") else { + throw FeatureFlagError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "PATCH" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 10 + + let body: [String: Any] = ["enabled": enabled] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (_, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + throw FeatureFlagError.requestFailed(statusCode) + } + #else + // iOS: httpTransport targets the remote gateway, which serves feature-flag routes. + guard let httpTransport else { + throw FeatureFlagError.requestFailed(0) + } + try await httpTransport.setFeatureFlag(key: key, enabled: enabled, featureFlagToken: token) + #endif + } + + public enum FeatureFlagError: Error, LocalizedError { + case missingToken + case invalidURL + case requestFailed(Int) + + public var errorDescription: String? { + switch self { + case .missingToken: + return "Feature-flag token not available" + case .invalidURL: + return "Invalid feature-flag endpoint URL" + case .requestFailed(let code): + return "Feature-flag request failed (HTTP \(code))" + } + } + } + } diff --git a/clients/shared/IPC/DaemonConfig.swift b/clients/shared/IPC/DaemonConfig.swift index 23ab182cded..b9e5906a122 100644 --- a/clients/shared/IPC/DaemonConfig.swift +++ b/clients/shared/IPC/DaemonConfig.swift @@ -50,6 +50,11 @@ public struct DaemonConfig { public let transport: Transport + /// Feature-flag bearer token for authenticating PATCH /v1/feature-flags/:flagKey requests. + /// On macOS this is read from `~/.vellum/feature-flag-token`. + /// On iOS this is received during QR pairing and stored in the Keychain. + public let featureFlagToken: String? + #if os(macOS) /// Socket path, for backwards compatibility. /// Returns the socket path if using socket transport, otherwise the default path. @@ -65,6 +70,7 @@ public struct DaemonConfig { /// Convenience initializer for socket transport (backwards compatible). public init(socketPath: String) { self.transport = .socket(path: socketPath) + self.featureFlagToken = readFeatureFlagToken() } public static var `default`: DaemonConfig { @@ -72,8 +78,13 @@ public struct DaemonConfig { } #endif - public init(transport: Transport) { + public init(transport: Transport, featureFlagToken: String? = nil) { self.transport = transport + #if os(macOS) + self.featureFlagToken = featureFlagToken ?? readFeatureFlagToken() + #else + self.featureFlagToken = featureFlagToken + #endif } #if os(iOS) @@ -90,6 +101,7 @@ public struct DaemonConfig { public static func fromUserDefaults() -> DaemonConfig { // gateway_base_url is set by QR pairing (v4). let httpBaseURL = UserDefaults.standard.string(forKey: "gateway_base_url").flatMap { $0.isEmpty ? nil : $0 } + let featureFlagToken = APIKeyManager.shared.getAPIKey(provider: "feature-flag-token") if let baseURL = httpBaseURL { let bearerToken = APIKeyManager.shared.getAPIKey(provider: "runtime-bearer-token") let conversationKey: String @@ -99,12 +111,12 @@ public struct DaemonConfig { conversationKey = UUID().uuidString UserDefaults.standard.set(conversationKey, forKey: "conversation_key") } - return DaemonConfig(transport: .http(baseURL: baseURL, bearerToken: bearerToken, conversationKey: conversationKey)) + return DaemonConfig(transport: .http(baseURL: baseURL, bearerToken: bearerToken, conversationKey: conversationKey), featureFlagToken: featureFlagToken) } // No gateway URL configured — return a placeholder HTTP config that won't connect. // The user needs to pair via QR code (which sets gateway_base_url) before connecting. - return DaemonConfig(transport: .http(baseURL: "", bearerToken: nil, conversationKey: "")) + return DaemonConfig(transport: .http(baseURL: "", bearerToken: nil, conversationKey: ""), featureFlagToken: featureFlagToken) } #endif } diff --git a/clients/shared/IPC/HTTPDaemonClient.swift b/clients/shared/IPC/HTTPDaemonClient.swift index 5f8c98ac3c5..f2c9b0e6bd3 100644 --- a/clients/shared/IPC/HTTPDaemonClient.swift +++ b/clients/shared/IPC/HTTPDaemonClient.swift @@ -589,6 +589,44 @@ final class HTTPTransport { } } + // MARK: - Feature Flags + + /// Toggle a feature flag via the gateway's PATCH endpoint. + /// Uses the dedicated feature-flag token (not the runtime bearer token) for auth. + func setFeatureFlag(key: String, enabled: Bool, featureFlagToken: String) async throws { + let encoded = key.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? key + guard let url = URL(string: "\(baseURL)/v1/feature-flags/\(encoded)") else { + throw HTTPTransportError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "PATCH" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(featureFlagToken)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 10 + + let body: [String: Any] = ["enabled": enabled] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw HTTPTransportError.healthCheckFailed + } + + if http.statusCode == 401 { + log.error("Feature flag PATCH failed: authentication error (401)") + throw HTTPTransportError.healthCheckFailed + } + + guard (200..<300).contains(http.statusCode) else { + let errorBody = String(data: data, encoding: .utf8) ?? "unknown" + log.error("Feature flag PATCH failed (\(http.statusCode)): \(errorBody)") + throw HTTPTransportError.healthCheckFailed + } + + log.info("Feature flag '\(key)' set to \(enabled)") + } + // MARK: - Remote Identity /// Fetch identity info from the remote daemon's `GET /v1/identity` endpoint.