feat: seamless cloud API key flow — save once, works automatically

- Tauri commands: save_cloud_key writes keys to ~/.openjarvis/cloud-keys.env
  (chmod 600), get_cloud_key_status reports which providers are configured.
- Server spawn: reads cloud-keys.env and injects keys as env vars into
  the jarvis serve process, so CloudEngine picks them up automatically.
- Frontend: Cloud Models tab saves keys through Tauri invoke (desktop)
  in addition to localStorage (web), so keys persist across restarts.
- Flow: user enters key in Cloud tab → saved to disk → next server
  start picks it up → cloud models appear in the model list.
This commit is contained in:
Jon Saad-Falcon
2026-03-14 14:32:46 -07:00
parent 93344a787f
commit 1a148debe2
2 changed files with 97 additions and 3 deletions
+85
View File
@@ -496,6 +496,11 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.current_dir(root);
// Inject cloud API keys from ~/.openjarvis/cloud-keys.env
for (key, value) in read_cloud_keys() {
cmd.env(&key, &value);
}
let jarvis_child = cmd.spawn();
match jarvis_child {
@@ -798,6 +803,84 @@ async fn submit_savings(
Ok(resp.status().is_success())
}
// ---------------------------------------------------------------------------
// Cloud API key management
// ---------------------------------------------------------------------------
/// Path to the cloud keys file (~/.openjarvis/cloud-keys.env).
fn cloud_keys_path() -> std::path::PathBuf {
let home = std::env::var("HOME").unwrap_or_default();
std::path::PathBuf::from(home)
.join(".openjarvis")
.join("cloud-keys.env")
}
/// Read cloud keys from disk and return as key=value pairs.
fn read_cloud_keys() -> Vec<(String, String)> {
let path = cloud_keys_path();
let mut keys = Vec::new();
if let Ok(contents) = std::fs::read_to_string(&path) {
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
keys.push((k.trim().to_string(), v.trim().to_string()));
}
}
}
keys
}
/// Save a single cloud API key to the keys file.
#[tauri::command]
async fn save_cloud_key(key_name: String, key_value: String) -> Result<(), String> {
let path = cloud_keys_path();
// Ensure directory exists
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// Read existing keys, update/add the one being saved
let mut keys: Vec<(String, String)> = read_cloud_keys()
.into_iter()
.filter(|(k, _)| k != &key_name)
.collect();
if !key_value.is_empty() {
keys.push((key_name, key_value));
}
// Write back
let content: String = keys
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(&path, content + "\n")
.map_err(|e| format!("Failed to save key: {}", e))?;
// Set permissions to owner-only (chmod 600)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
/// Get which cloud providers have keys configured (without exposing values).
#[tauri::command]
async fn get_cloud_key_status() -> Result<serde_json::Value, String> {
let keys = read_cloud_keys();
let status: Vec<serde_json::Value> = keys
.iter()
.map(|(k, v)| serde_json::json!({ "key": k, "set": !v.is_empty() }))
.collect();
Ok(serde_json::json!(status))
}
/// Pull a model via Ollama (called from frontend download button).
#[tauri::command]
async fn pull_ollama_model(model_name: String) -> Result<serde_json::Value, String> {
@@ -940,6 +1023,8 @@ pub fn run() {
speech_health,
pull_ollama_model,
delete_ollama_model,
save_cloud_key,
get_cloud_key_status,
])
.build(tauri::generate_context!())
.expect("error while building OpenJarvis Desktop")
+12 -3
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { Search, Cpu, X, Download, Loader2, Trash2, Check, Cloud, Key, Eye, EyeOff } from 'lucide-react';
import { useAppStore } from '../lib/store';
import { pullModel, deleteModel, fetchModels, preloadModel } from '../lib/api';
import { pullModel, deleteModel, fetchModels, preloadModel, isTauri } from '../lib/api';
/** Popular models that users can download from the catalogue. */
const CATALOGUE_MODELS = [
@@ -209,12 +209,21 @@ export function CommandPalette() {
setCustomModel('');
};
const handleSaveKey = (provider: CloudProvider, value: string) => {
const handleSaveKey = async (provider: CloudProvider, value: string) => {
setStoredKey(provider.storageKey, value);
setApiKeys((prev) => ({ ...prev, [provider.storageKey]: value }));
// Also save to Tauri backend so the server process picks up the key
if (isTauri()) {
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('save_cloud_key', { keyName: provider.envKey, keyValue: value });
} catch {}
}
useAppStore.getState().addLogEntry({
timestamp: Date.now(), level: 'info', category: 'model',
message: `${provider.name} API key ${value ? 'saved' : 'removed'}`,
message: `${provider.name} API key ${value ? 'saved' : 'removed'}. Restart the app for cloud models to appear.`,
});
};