Skip to content

Commit dbd29bc

Browse files
authored
Move API keys from bundled .env to backend-served (#5726)
## Summary - New `GET /v1/config/api-keys` backend endpoint serves Deepgram, Gemini, and Anthropic keys to authenticated users - New `APIKeyService` singleton fetches keys from backend after sign-in, holds in memory only (never persisted to disk) - Bundled `.env` keys loaded as fallback during transition, overwritten once backend keys arrive - Developer API Keys UI in Settings > Advanced for custom key overrides (like mobile app pattern) - Keys cleared on sign-out - Cloud Run backend already updated with all 3 keys in env vars ## How it works 1. App starts → loads `.env` (fallback keys for transition) 2. User signs in → `APIKeyService.fetchKeys()` calls backend 3. Backend returns keys → set in memory via `setenv()` 4. ACP bridge, Gemini, Deepgram all use the backend-provided keys 5. Developer overrides in Settings take precedence over backend keys ## Next steps (after this merges) - Remove API keys from `OMI_DESKTOP_APP_ENV` Codemagic secret (once backend endpoint is deployed) - Proxy Deepgram/Gemini calls through backend (eliminates key exposure entirely) ## Test plan - [x] Both Swift and Rust builds compile - [x] Backend endpoint returns all 3 keys to authenticated user (tested with curl) - [x] App fetches keys from backend: "Fetched keys from backend (deepgram=true, gemini=true, anthropic=true)" - [x] Fallback works when backend unavailable - [x] Chat works with backend-served keys - [x] Retry with backoff (3 attempts, 1s/2s/4s) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents f0dcd2e + ce0ddd2 commit dbd29bc

10 files changed

Lines changed: 250 additions & 7 deletions

File tree

desktop/Backend-Rust/src/config.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ pub struct Config {
6464
pub gce_source_image: String,
6565
/// GCS bucket for agent startup script (defaults to "based-hardware-agent")
6666
pub agent_gcs_bucket: String,
67+
/// Deepgram API key for transcription (served to desktop clients)
68+
pub deepgram_api_key: Option<String>,
69+
/// Anthropic API key for chat (served to desktop clients)
70+
pub anthropic_api_key: Option<String>,
6771
}
6872

6973
impl Config {
@@ -123,6 +127,8 @@ impl Config {
123127
},
124128
agent_gcs_bucket: env::var("AGENT_GCS_BUCKET")
125129
.unwrap_or_else(|_| "based-hardware-agent".to_string()),
130+
deepgram_api_key: env::var("DEEPGRAM_API_KEY").ok(),
131+
anthropic_api_key: env::var("ANTHROPIC_API_KEY").ok(),
126132
}
127133
}
128134

desktop/Backend-Rust/src/main.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ mod services;
3232

3333
use auth::{firebase_auth_extension, FirebaseAuth};
3434
use config::Config;
35-
use routes::{action_items_routes, advice_routes, agent_routes, apps_routes, auth_routes, chat_routes, chat_sessions_routes, conversations_routes, crisp_routes, daily_score_routes, focus_sessions_routes, folder_routes, goals_routes, health_routes, knowledge_graph_routes, llm_usage_routes, memories_routes, messages_routes, people_routes, personas_routes, screen_activity_routes, staged_tasks_routes, stats_routes, updates_routes, users_routes, webhook_routes};
35+
use routes::{action_items_routes, advice_routes, agent_routes, apps_routes, auth_routes, chat_routes, chat_sessions_routes, config_routes, conversations_routes, crisp_routes, daily_score_routes, focus_sessions_routes, folder_routes, goals_routes, health_routes, knowledge_graph_routes, llm_usage_routes, memories_routes, messages_routes, people_routes, personas_routes, screen_activity_routes, staged_tasks_routes, stats_routes, updates_routes, users_routes, webhook_routes};
3636
use services::{FirestoreService, IntegrationService, RedisService};
3737

3838
/// Application state shared across handlers
@@ -204,6 +204,7 @@ async fn main() {
204204
.merge(webhook_routes())
205205
.merge(crisp_routes())
206206
.merge(screen_activity_routes())
207+
.merge(config_routes())
207208
.with_state(state);
208209

209210
// Merge both (now both are Router<()>), then add layers
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Client configuration routes
2+
// Serves API keys to authenticated desktop clients so keys are not bundled in the app binary.
3+
4+
use axum::{extract::State, routing::get, Json, Router};
5+
use serde::Serialize;
6+
7+
use crate::auth::AuthUser;
8+
use crate::AppState;
9+
10+
#[derive(Serialize)]
11+
struct ApiKeysResponse {
12+
#[serde(skip_serializing_if = "Option::is_none")]
13+
deepgram_api_key: Option<String>,
14+
#[serde(skip_serializing_if = "Option::is_none")]
15+
gemini_api_key: Option<String>,
16+
#[serde(skip_serializing_if = "Option::is_none")]
17+
anthropic_api_key: Option<String>,
18+
}
19+
20+
/// GET /v1/config/api-keys — return API keys for the authenticated user
21+
async fn get_api_keys(State(state): State<AppState>, _user: AuthUser) -> Json<ApiKeysResponse> {
22+
Json(ApiKeysResponse {
23+
deepgram_api_key: state.config.deepgram_api_key.clone(),
24+
gemini_api_key: state.config.gemini_api_key.clone(),
25+
anthropic_api_key: state.config.anthropic_api_key.clone(),
26+
})
27+
}
28+
29+
pub fn config_routes() -> Router<AppState> {
30+
Router::new().route("/v1/config/api-keys", get(get_api_keys))
31+
}

desktop/Backend-Rust/src/routes/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub mod apps;
77
pub mod auth;
88
pub mod chat;
99
pub mod chat_sessions;
10+
pub mod config;
1011
pub mod conversations;
1112
pub mod crisp;
1213
pub mod daily_score;
@@ -31,6 +32,7 @@ pub use action_items::action_items_routes;
3132
pub use advice::advice_routes;
3233
pub use agent::agent_routes;
3334
pub use apps::apps_routes;
35+
pub use config::config_routes;
3436
pub use auth::auth_routes;
3537
pub use chat::chat_routes;
3638
pub use chat_sessions::chat_sessions_routes;

desktop/Desktop/Sources/APIClient.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4571,4 +4571,22 @@ extension APIClient {
45714571
return nil
45724572
}
45734573
}
4574+
4575+
// MARK: - API Keys
4576+
4577+
struct ApiKeysResponse: Decodable {
4578+
let deepgramApiKey: String?
4579+
let geminiApiKey: String?
4580+
let anthropicApiKey: String?
4581+
4582+
enum CodingKeys: String, CodingKey {
4583+
case deepgramApiKey = "deepgram_api_key"
4584+
case geminiApiKey = "gemini_api_key"
4585+
case anthropicApiKey = "anthropic_api_key"
4586+
}
4587+
}
4588+
4589+
func fetchApiKeys() async throws -> ApiKeysResponse {
4590+
return try await get("v1/config/api-keys")
4591+
}
45744592
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import Foundation
2+
3+
/// Fetches API keys from the backend at runtime instead of bundling them in the app.
4+
/// Developer overrides (set in Settings) take precedence over backend-provided keys.
5+
@MainActor
6+
final class APIKeyService: ObservableObject {
7+
static let shared = APIKeyService()
8+
9+
// Backend-provided keys (in-memory only, never persisted to disk)
10+
@Published private(set) var deepgramApiKey: String?
11+
@Published private(set) var geminiApiKey: String?
12+
@Published private(set) var anthropicApiKey: String?
13+
@Published private(set) var isLoaded: Bool = false
14+
@Published private(set) var loadError: String?
15+
16+
/// Effective key: developer override > backend-provided > nil
17+
var effectiveDeepgramKey: String? {
18+
nonEmpty(UserDefaults.standard.string(forKey: "dev_deepgram_api_key")) ?? deepgramApiKey
19+
}
20+
21+
var effectiveGeminiKey: String? {
22+
nonEmpty(UserDefaults.standard.string(forKey: "dev_gemini_api_key")) ?? geminiApiKey
23+
}
24+
25+
var effectiveAnthropicKey: String? {
26+
nonEmpty(UserDefaults.standard.string(forKey: "dev_anthropic_api_key")) ?? anthropicApiKey
27+
}
28+
29+
/// Fetch keys from the backend. Call after Firebase auth is ready.
30+
func fetchKeys() async {
31+
loadError = nil
32+
33+
// Retry up to 3 times with backoff
34+
for attempt in 1...3 {
35+
do {
36+
let keys = try await APIClient.shared.fetchApiKeys()
37+
self.deepgramApiKey = keys.deepgramApiKey
38+
self.geminiApiKey = keys.geminiApiKey
39+
self.anthropicApiKey = keys.anthropicApiKey
40+
self.isLoaded = true
41+
42+
// Set env vars so existing getenv() consumers keep working during transition
43+
applyToEnvironment()
44+
45+
log("APIKeyService: Fetched keys from backend (deepgram=\(keys.deepgramApiKey != nil), gemini=\(keys.geminiApiKey != nil), anthropic=\(keys.anthropicApiKey != nil))")
46+
return
47+
} catch {
48+
let delay = pow(2.0, Double(attempt - 1))
49+
log("APIKeyService: Fetch attempt \(attempt)/3 failed: \(error.localizedDescription), retrying in \(delay)s")
50+
if attempt < 3 {
51+
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
52+
}
53+
}
54+
}
55+
56+
loadError = "Failed to fetch API keys from backend"
57+
log("APIKeyService: All fetch attempts failed — features requiring API keys will be unavailable")
58+
59+
// Still apply env vars from developer overrides if set
60+
applyToEnvironment()
61+
}
62+
63+
/// Clear all keys (e.g. on sign-out)
64+
func clear() {
65+
deepgramApiKey = nil
66+
geminiApiKey = nil
67+
anthropicApiKey = nil
68+
isLoaded = false
69+
loadError = nil
70+
71+
unsetenv("DEEPGRAM_API_KEY")
72+
unsetenv("GEMINI_API_KEY")
73+
unsetenv("ANTHROPIC_API_KEY")
74+
}
75+
76+
/// Push effective keys into the process environment for backward compatibility.
77+
private func applyToEnvironment() {
78+
if let key = effectiveDeepgramKey {
79+
setenv("DEEPGRAM_API_KEY", key, 1)
80+
}
81+
if let key = effectiveGeminiKey {
82+
setenv("GEMINI_API_KEY", key, 1)
83+
}
84+
if let key = effectiveAnthropicKey {
85+
setenv("ANTHROPIC_API_KEY", key, 1)
86+
}
87+
}
88+
89+
private func nonEmpty(_ s: String?) -> String? {
90+
guard let s, !s.trimmingCharacters(in: .whitespaces).isEmpty else { return nil }
91+
return s
92+
}
93+
}

desktop/Desktop/Sources/AppState.swift

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,13 @@ class AppState: ObservableObject {
422422
let key = String(parts[0]).trimmingCharacters(in: .whitespaces)
423423
// Skip comments
424424
guard !key.hasPrefix("#") else { continue }
425+
// API keys are fetched from the backend at runtime (APIKeyService).
426+
// Load them from .env as a fallback — APIKeyService.fetchKeys() will
427+
// overwrite them with backend-provided keys once auth is ready.
428+
let backendServedKeys = ["DEEPGRAM_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY"]
429+
if backendServedKeys.contains(key) {
430+
log(" Set \(key)=*** (fallback, will be overwritten by backend)")
431+
}
425432
let value = String(parts[1]).trimmingCharacters(in: .whitespaces)
426433
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
427434
setenv(key, value, 1)
@@ -435,12 +442,7 @@ class AppState: ObservableObject {
435442
}
436443
}
437444

438-
// Log final state of important keys
439-
if getenv("DEEPGRAM_API_KEY") != nil {
440-
log("DEEPGRAM_API_KEY is set")
441-
} else {
442-
log("WARNING: DEEPGRAM_API_KEY is NOT set")
443-
}
445+
log("Environment loaded (API keys will be fetched from backend after auth)")
444446
}
445447

446448

desktop/Desktop/Sources/AuthService.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ class AuthService {
321321

322322
AnalyticsManager.shared.identify()
323323
AnalyticsManager.shared.signInCompleted(provider: "apple")
324+
Task { await APIKeyService.shared.fetchKeys() }
324325

325326
if !AnalyticsManager.isDevBuild {
326327
let sentryUser = User(userId: userId)
@@ -436,6 +437,7 @@ class AuthService {
436437
// (identify must happen before events for PostHog person profiles to work)
437438
AnalyticsManager.shared.identify()
438439
AnalyticsManager.shared.signInCompleted(provider: provider)
440+
Task { await APIKeyService.shared.fetchKeys() }
439441

440442
// Set Sentry user context for error tracking (skip in dev builds)
441443
if !AnalyticsManager.isDevBuild {
@@ -1010,6 +1012,7 @@ class AuthService {
10101012

10111013
try Auth.auth().signOut()
10121014
isSignedIn = false
1015+
APIKeyService.shared.clear()
10131016
// Clear saved auth state and tokens
10141017
saveAuthState(isSignedIn: false, email: nil, userId: nil)
10151018
clearTokens()

desktop/Desktop/Sources/MainWindow/Pages/SettingsPage.swift

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ struct SettingsContentView: View {
261261
case preferences = "Preferences"
262262
case troubleshooting = "Troubleshooting"
263263
case gmailReader = "Gmail Reader"
264+
case developerKeys = "Developer API Keys"
264265

265266
var icon: String {
266267
switch self {
@@ -277,6 +278,7 @@ struct SettingsContentView: View {
277278
case .preferences: return "slider.horizontal.3"
278279
case .troubleshooting: return "wrench.and.screwdriver"
279280
case .gmailReader: return "envelope.fill"
281+
case .developerKeys: return "key"
280282
}
281283
}
282284
}
@@ -295,6 +297,11 @@ struct SettingsContentView: View {
295297
@State private var isDeletingAccount: Bool = false
296298
@State private var deleteAccountError: String?
297299

300+
// Developer API Key overrides
301+
@AppStorage("dev_deepgram_api_key") private var devDeepgramKey: String = ""
302+
@AppStorage("dev_gemini_api_key") private var devGeminiKey: String = ""
303+
@AppStorage("dev_anthropic_api_key") private var devAnthropicKey: String = ""
304+
298305
init(
299306
appState: AppState,
300307
selectedSection: Binding<SettingsSection>,
@@ -2513,6 +2520,8 @@ struct SettingsContentView: View {
25132520
troubleshootingSubsection
25142521
advancedCategoryHeader(title: "Gmail Reader", icon: "envelope.fill")
25152522
gmailReaderSubsection
2523+
advancedCategoryHeader(title: "Developer API Keys", icon: "key")
2524+
developerKeysSubsection
25162525
}
25172526
}
25182527

@@ -4102,6 +4111,79 @@ struct SettingsContentView: View {
41024111
return formatter
41034112
}
41044113

4114+
// MARK: - Developer API Keys Subsection
4115+
4116+
private var developerKeysSubsection: some View {
4117+
VStack(spacing: 20) {
4118+
settingsCard(settingId: "advanced.devkeys.info") {
4119+
HStack(spacing: 12) {
4120+
Image(systemName: "info.circle")
4121+
.foregroundColor(OmiColors.textTertiary)
4122+
Text("Override backend-provided API keys with your own. Leave blank to use default keys.")
4123+
.scaledFont(size: 13)
4124+
.foregroundColor(OmiColors.textTertiary)
4125+
Spacer()
4126+
}
4127+
}
4128+
4129+
developerKeyField(
4130+
title: "Deepgram API Key",
4131+
subtitle: "For transcription",
4132+
settingId: "advanced.devkeys.deepgram",
4133+
value: $devDeepgramKey
4134+
)
4135+
4136+
developerKeyField(
4137+
title: "Gemini API Key",
4138+
subtitle: "For proactive AI (memory, tasks, advice, focus)",
4139+
settingId: "advanced.devkeys.gemini",
4140+
value: $devGeminiKey
4141+
)
4142+
4143+
developerKeyField(
4144+
title: "Anthropic API Key",
4145+
subtitle: "For chat (Claude)",
4146+
settingId: "advanced.devkeys.anthropic",
4147+
value: $devAnthropicKey
4148+
)
4149+
4150+
if !devDeepgramKey.isEmpty || !devGeminiKey.isEmpty || !devAnthropicKey.isEmpty {
4151+
settingsCard(settingId: "advanced.devkeys.clear") {
4152+
HStack {
4153+
Spacer()
4154+
Button(action: {
4155+
devDeepgramKey = ""
4156+
devGeminiKey = ""
4157+
devAnthropicKey = ""
4158+
}) {
4159+
Text("Clear All Custom Keys")
4160+
.scaledFont(size: 13, weight: .medium)
4161+
.foregroundColor(.red)
4162+
}
4163+
.buttonStyle(.plain)
4164+
Spacer()
4165+
}
4166+
}
4167+
}
4168+
}
4169+
}
4170+
4171+
private func developerKeyField(title: String, subtitle: String, settingId: String, value: Binding<String>) -> some View {
4172+
settingsCard(settingId: settingId) {
4173+
VStack(alignment: .leading, spacing: 8) {
4174+
Text(title)
4175+
.scaledFont(size: 14, weight: .medium)
4176+
.foregroundColor(OmiColors.textPrimary)
4177+
Text(subtitle)
4178+
.scaledFont(size: 12)
4179+
.foregroundColor(OmiColors.textTertiary)
4180+
SecureField("Leave blank for default", text: value)
4181+
.textFieldStyle(.roundedBorder)
4182+
.scaledFont(size: 13)
4183+
}
4184+
}
4185+
}
4186+
41054187
private func tierPickerRow(tier: Int, label: String, subtitle: String) -> some View {
41064188
let isSelected = currentTierLevel == tier
41074189
return Button(action: {

desktop/Desktop/Sources/OmiApp.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
315315
// Fetch conversations on startup
316316
AuthService.shared.fetchConversations()
317317

318+
// Fetch API keys from backend (keys are not bundled in the app)
319+
Task {
320+
await APIKeyService.shared.fetchKeys()
321+
}
322+
318323
// Check tier eligibility (at most once per day)
319324
Task {
320325
await TierManager.shared.checkTierIfNeeded()

0 commit comments

Comments
 (0)