Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions clients/ios/Views/Settings/QRPairingSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand All @@ -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")
Comment thread
noanflaherty marked this conversation as resolved.
} 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 {
Expand Down
90 changes: 90 additions & 0 deletions clients/shared/IPC/DaemonClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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))"
}
}
}

}
18 changes: 15 additions & 3 deletions clients/shared/IPC/DaemonConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -65,15 +70,21 @@ 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 {
return DaemonConfig(transport: .socket(path: resolveSocketPath()))
}
#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)
Expand All @@ -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
Expand All @@ -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
}
38 changes: 38 additions & 0 deletions clients/shared/IPC/HTTPDaemonClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down