Skip to main content

nexus_core/
config.rs

1use std::fmt::Write as _;
2use std::path::PathBuf;
3
4use anyhow::{Context, Result, bail};
5use base64::Engine;
6use directories::ProjectDirs;
7use serde::{Deserialize, Serialize};
8
9pub const OPENROUTER_ENV_KEY: &str = "OPENROUTER_API_KEY";
10pub const OPENAI_ENV_KEY: &str = "OPENAI_API_KEY";
11pub const OPENCODE_ENV_KEY: &str = "OPENCODE_API_KEY";
12
13const DEFAULT_SYSTEM_PROMPT: &str = include_str!("../assets/system-prompt-base.md");
14
15#[derive(Debug, Default, Deserialize)]
16struct Config {
17    #[serde(default)]
18    provider: Provider,
19    #[serde(default)]
20    host: Option<HostSettings>,
21}
22
23#[derive(Debug, Default, Deserialize)]
24struct Provider {
25    #[serde(default)]
26    openrouter_key: String,
27    #[serde(default)]
28    openai_key: String,
29    #[serde(default)]
30    opencode_key: String,
31    #[serde(default)]
32    host_token: Option<String>,
33    #[serde(default)]
34    openai_codex: Option<CodexCredentials>,
35}
36
37#[derive(Debug, Clone, Deserialize, Serialize)]
38pub struct CodexCredentials {
39    pub access: String,
40    pub refresh: String,
41    pub expires: i64,
42    pub account_id: String,
43}
44
45/// Persisted, non-secret configuration for a named Cloudflare tunnel.
46#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
47pub struct NamedTunnelConfig {
48    pub tunnel_id: String,
49    pub hostname: String,
50    pub credentials_path: PathBuf,
51    pub config_path: PathBuf,
52}
53
54#[derive(Debug, Default, Deserialize)]
55struct HostSettings {
56    #[serde(default)]
57    named_tunnel: Option<NamedTunnelConfig>,
58}
59
60/// Every credential the app knows about at once — every configured backend
61/// is simultaneously usable (`/model` merges all of their catalogs).
62#[derive(Debug, Clone, Default)]
63pub struct SavedCreds {
64    pub openrouter_key: Option<String>,
65    pub openai_key: Option<String>,
66    pub opencode_key: Option<String>,
67    pub codex: Option<CodexCredentials>,
68    /// Bearer token protecting the local host API, persisted under
69    /// `[provider].host_token` and never synced with space data.
70    pub host_token: Option<String>,
71}
72
73/// XDG dirs for the app: `~/.config/nexus-chat` and `~/.local/share/nexus-chat`.
74pub fn project_dirs() -> Result<ProjectDirs> {
75    ProjectDirs::from("", "", "nexus-chat").context("could not resolve home directory")
76}
77
78pub fn config_path() -> Result<PathBuf> {
79    Ok(project_dirs()?.config_dir().join("config.toml"))
80}
81
82/// Optional custom start-screen banner: paste any ASCII art into
83/// `~/.config/nexus-chat/banner.txt` and it replaces the built-in one.
84pub fn load_banner() -> Option<String> {
85    let path = project_dirs().ok()?.config_dir().join("banner.txt");
86    let art = std::fs::read_to_string(path).ok()?;
87    (!art.trim().is_empty()).then(|| art.trim_end().to_string())
88}
89
90/// The base system prompt file (identity/formatting/scope, with a
91/// `{{verbosity}}` placeholder App fills in). Lives beside config.toml, not
92/// per-space — this is app-level, not chat-level. Editable via `$EDITOR`.
93pub fn system_prompt_path() -> Result<PathBuf> {
94    Ok(project_dirs()?.config_dir().join("system_prompt.md"))
95}
96
97/// Read the base system prompt, scaffolding the built-in default on first run.
98pub fn load_system_prompt() -> Result<String> {
99    let path = system_prompt_path()?;
100    if !path.exists() {
101        if let Some(dir) = path.parent() {
102            std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
103        }
104        std::fs::write(&path, DEFAULT_SYSTEM_PROMPT)
105            .with_context(|| format!("writing {}", path.display()))?;
106        return Ok(DEFAULT_SYSTEM_PROMPT.to_string());
107    }
108    std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))
109}
110
111/// Resolve every configured credential from disk + env, with no network
112/// access: Codex creds are returned exactly as stored (no refresh). Read-only
113/// CLI commands (`status`, `usage`, `sessions`, …) use this so they never
114/// touch the network; the TUI and `nexus ask` use `load_all_providers`,
115/// which additionally refreshes a stale Codex token.
116pub fn load_creds_offline() -> SavedCreds {
117    let (mut openrouter_key, mut openai_key, mut opencode_key, codex) =
118        load_config_all().unwrap_or_default();
119    let host_token = load_host_token().ok().flatten();
120    if openrouter_key.is_empty()
121        && let Ok(v) = std::env::var(OPENROUTER_ENV_KEY)
122        && !v.trim().is_empty()
123    {
124        openrouter_key = v.trim().to_string();
125    }
126    if openai_key.is_empty()
127        && let Ok(v) = std::env::var(OPENAI_ENV_KEY)
128        && !v.trim().is_empty()
129    {
130        openai_key = v.trim().to_string();
131    }
132    if opencode_key.is_empty()
133        && let Ok(v) = std::env::var(OPENCODE_ENV_KEY)
134        && !v.trim().is_empty()
135    {
136        opencode_key = v.trim().to_string();
137    }
138    SavedCreds {
139        openrouter_key: (!openrouter_key.is_empty()).then_some(openrouter_key),
140        openai_key: (!openai_key.is_empty()).then_some(openai_key),
141        opencode_key: (!opencode_key.is_empty()).then_some(opencode_key),
142        codex,
143        host_token,
144    }
145}
146
147/// Resolve every configured credential at once: `$OPENROUTER_API_KEY`/
148/// `$OPENAI_API_KEY`/`$OPENCODE_API_KEY` (if set) win over the config file
149/// for their respective slot, Codex creds always come from the config file
150/// (refreshed if stale). Scaffolds an empty config on first run. Never
151/// fails on missing credentials — the app launches regardless and they can
152/// be set in-app with `/login`.
153pub async fn load_all_providers() -> Result<SavedCreds> {
154    let path = config_path()?;
155    if !path.exists() {
156        write_provider_config("", "", "", None)?; // scaffold template
157    }
158    let mut saved = load_creds_offline();
159    if let Some(creds) = saved.codex.take() {
160        let creds = refresh_codex_if_needed(creds).await?;
161        save_codex_credentials(&creds)?;
162        saved.codex = Some(creds);
163    }
164    Ok(saved)
165}
166
167/// The first configured credential in a fixed priority order (openrouter >
168/// openai > opencode > codex) — used only to seed `App::new`'s "reasonable
169/// defaults" guess at startup; `App::rebuild_all_backends` populates every
170/// configured backend regardless of which one this picks.
171pub fn first_configured(saved: &SavedCreds) -> Option<(&'static str, String)> {
172    saved
173        .openrouter_key
174        .clone()
175        .map(|k| ("openrouter", k))
176        .or_else(|| saved.openai_key.clone().map(|k| ("openai", k)))
177        .or_else(|| saved.opencode_key.clone().map(|k| ("opencode", k)))
178        .or_else(|| saved.codex.as_ref().map(|c| ("codex", c.access.clone())))
179}
180
181pub fn codex_account_id(access_token: &str) -> Result<String> {
182    let payload = access_token
183        .split('.')
184        .nth(1)
185        .context("invalid Codex access token")?;
186    let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
187        .decode(payload)
188        .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload))
189        .context("decoding Codex access token")?;
190    let v: serde_json::Value = serde_json::from_slice(&payload).context("parsing Codex token")?;
191    let account = v
192        .get("https://api.openai.com/auth")
193        .and_then(|a| a.get("chatgpt_account_id"))
194        .and_then(|a| a.as_str())
195        .context("Codex token missing ChatGPT account id")?;
196    Ok(account.to_string())
197}
198
199async fn refresh_codex_if_needed(creds: CodexCredentials) -> Result<CodexCredentials> {
200    if chrono::Utc::now().timestamp_millis() < creds.expires - 60_000 {
201        return Ok(creds);
202    }
203    let resp = reqwest::Client::new()
204        .post("https://auth.openai.com/oauth/token")
205        .header("Content-Type", "application/x-www-form-urlencoded")
206        .form(&[
207            ("grant_type", "refresh_token"),
208            ("refresh_token", creds.refresh.as_str()),
209            ("client_id", "app_EMoamEEZ73f0CkXaXp7hrann"),
210        ])
211        .send()
212        .await
213        .context("refreshing OpenAI Codex token")?
214        .error_for_status()
215        .context("OpenAI Codex token refresh failed")?
216        .json::<serde_json::Value>()
217        .await
218        .context("parsing OpenAI Codex token refresh")?;
219    let access = resp
220        .get("access_token")
221        .and_then(|v| v.as_str())
222        .context("missing access_token")?
223        .to_string();
224    let refresh = resp
225        .get("refresh_token")
226        .and_then(|v| v.as_str())
227        .unwrap_or(&creds.refresh)
228        .to_string();
229    let expires_in = resp
230        .get("expires_in")
231        .and_then(serde_json::Value::as_i64)
232        .context("missing expires_in")?;
233    Ok(CodexCredentials {
234        account_id: codex_account_id(&access)?,
235        access,
236        refresh,
237        expires: chrono::Utc::now().timestamp_millis() + expires_in * 1000,
238    })
239}
240
241pub fn save_codex_credentials(creds: &CodexCredentials) -> Result<()> {
242    let (openrouter_key, openai_key, opencode_key, _) = load_config_all().unwrap_or_default();
243    write_provider_config(&openrouter_key, &openai_key, &opencode_key, Some(creds))
244}
245
246pub fn load_openrouter_key_only() -> Option<String> {
247    if let Ok(v) = std::env::var(OPENROUTER_ENV_KEY) {
248        let v = v.trim();
249        if !v.is_empty() {
250            return Some(v.to_string());
251        }
252    }
253    load_config_all()
254        .ok()
255        .and_then(|(openrouter_key, ..)| (!openrouter_key.is_empty()).then_some(openrouter_key))
256}
257
258/// Persist a provider's key by explicit flavor tag ("openrouter" / "openai"
259/// / "opencode") — called only from the `/login` provider selector, which
260/// always knows exactly which flavor a pasted key is for (no shape-sniffing).
261pub fn save_provider_key(flavor: &str, key: &str) -> Result<()> {
262    let (mut openrouter_key, mut openai_key, mut opencode_key, codex) =
263        load_config_all().unwrap_or_default();
264    match flavor {
265        "openrouter" => openrouter_key = key.to_string(),
266        "openai" => openai_key = key.to_string(),
267        "opencode" => opencode_key = key.to_string(),
268        _ => {}
269    }
270    write_provider_config(&openrouter_key, &openai_key, &opencode_key, codex.as_ref())
271}
272
273/// Read all credential fields straight off disk, no env overrides.
274fn load_config_all() -> Result<(String, String, String, Option<CodexCredentials>)> {
275    let path = config_path()?;
276    if !path.exists() {
277        return Ok((String::new(), String::new(), String::new(), None));
278    }
279    let text =
280        std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
281    let cfg: Config =
282        toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
283    Ok((
284        cfg.provider.openrouter_key,
285        cfg.provider.openai_key,
286        cfg.provider.opencode_key,
287        cfg.provider.openai_codex,
288    ))
289}
290
291/// Read the optional host bearer token without exposing it in provider
292/// selection or diagnostics.
293pub fn load_host_token() -> Result<Option<String>> {
294    let path = config_path()?;
295    if !path.exists() {
296        return Ok(None);
297    }
298    let text =
299        std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
300    let cfg: Config =
301        toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
302    Ok(cfg
303        .provider
304        .host_token
305        .filter(|token| !token.trim().is_empty()))
306}
307
308/// Persist the host bearer token in the existing provider table while keeping
309/// all provider credentials and Codex fields intact.
310pub fn save_host_token(token: &str) -> Result<()> {
311    let path = config_path()?;
312    if let Some(dir) = path.parent() {
313        std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
314    }
315    let mut value = if path.exists() {
316        let text = std::fs::read_to_string(&path)
317            .with_context(|| format!("reading {}", path.display()))?;
318        toml::from_str::<toml::Value>(&text)
319            .with_context(|| format!("parsing {}", path.display()))?
320    } else {
321        toml::Value::Table(toml::map::Map::new())
322    };
323    let table = value
324        .as_table_mut()
325        .context("config root is not a TOML table")?;
326    let provider = table
327        .entry("provider".to_string())
328        .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
329    let provider = provider
330        .as_table_mut()
331        .context("config provider is not a TOML table")?;
332    provider.insert(
333        "host_token".to_string(),
334        toml::Value::String(token.to_string()),
335    );
336    let body = toml::to_string_pretty(&value).context("serializing config")?;
337    std::fs::write(&path, body).with_context(|| format!("writing {}", path.display()))?;
338    Ok(())
339}
340
341/// Return the persisted token or create a random one on the first host run.
342pub fn ensure_host_token() -> Result<String> {
343    if let Some(token) = load_host_token()? {
344        return Ok(token);
345    }
346    let token = uuid::Uuid::new_v4().to_string();
347    save_host_token(&token)?;
348    Ok(token)
349}
350
351/// Load a previously provisioned named tunnel without contacting Cloudflare.
352pub fn load_named_tunnel() -> Result<Option<NamedTunnelConfig>> {
353    let path = config_path()?;
354    if !path.exists() {
355        return Ok(None);
356    }
357    let text =
358        std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
359    let cfg: Config =
360        toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
361    Ok(cfg.host.and_then(|host| host.named_tunnel))
362}
363
364/// Persist named-tunnel identifiers and local config paths. Credentials stay
365/// in cloudflared's private file; this table contains no bearer/API secret.
366pub fn save_named_tunnel(tunnel: &NamedTunnelConfig) -> Result<()> {
367    let path = config_path()?;
368    if let Some(dir) = path.parent() {
369        std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
370    }
371    let mut value = if path.exists() {
372        let text = std::fs::read_to_string(&path)
373            .with_context(|| format!("reading {}", path.display()))?;
374        toml::from_str::<toml::Value>(&text)
375            .with_context(|| format!("parsing {}", path.display()))?
376    } else {
377        toml::Value::Table(toml::map::Map::new())
378    };
379    let root = value
380        .as_table_mut()
381        .context("config root is not a TOML table")?;
382    let host = root
383        .entry("host".to_string())
384        .or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
385        .as_table_mut()
386        .context("config host is not a TOML table")?;
387    host.insert(
388        "named_tunnel".to_string(),
389        toml::Value::try_from(tunnel).context("serializing named tunnel config")?,
390    );
391    let body = toml::to_string_pretty(&value).context("serializing config")?;
392    std::fs::write(&path, body).with_context(|| format!("writing {}", path.display()))?;
393    Ok(())
394}
395
396// Long by design (device-flow state machine).
397#[allow(clippy::too_many_lines)]
398pub async fn login_openai_codex_device(
399    status: tokio::sync::mpsc::UnboundedSender<String>,
400) -> Result<CodexCredentials> {
401    let client = reqwest::Client::new();
402    let device = client
403        .post("https://auth.openai.com/api/accounts/deviceauth/usercode")
404        .json(&serde_json::json!({ "client_id": "app_EMoamEEZ73f0CkXaXp7hrann" }))
405        .send()
406        .await
407        .context("starting OpenAI Codex device login")?
408        .error_for_status()
409        .context("OpenAI Codex device login failed")?
410        .json::<serde_json::Value>()
411        .await
412        .context("parsing OpenAI Codex device login")?;
413    let device_auth_id = device
414        .get("device_auth_id")
415        .and_then(|v| v.as_str())
416        .context("missing device_auth_id")?
417        .to_string();
418    let user_code = device
419        .get("user_code")
420        .and_then(|v| v.as_str())
421        .context("missing user_code")?
422        .to_string();
423    let interval = device
424        .get("interval")
425        .and_then(|v| v.as_f64().or_else(|| v.as_str()?.parse::<f64>().ok()))
426        .unwrap_or(5.0)
427        .max(1.0);
428    let url = "https://auth.openai.com/codex/device";
429    let prefilled_url = format!("{url}?user_code={user_code}");
430    // Put only the raw code first so it stays visible even on narrow status lines.
431    let _ =
432        arboard::Clipboard::new().and_then(|mut clipboard| clipboard.set_text(user_code.clone()));
433    let _ = status.send(format!(
434        "{user_code}  ← copied to clipboard; enter at {url}"
435    ));
436    let _ = open::that(&prefilled_url);
437
438    let deadline = std::time::Instant::now() + std::time::Duration::from_mins(15);
439    let code = loop {
440        if std::time::Instant::now() >= deadline {
441            bail!("OpenAI Codex device login timed out");
442        }
443        tokio::time::sleep(std::time::Duration::from_secs_f64(interval)).await;
444        let resp = client
445            .post("https://auth.openai.com/api/accounts/deviceauth/token")
446            .json(&serde_json::json!({ "device_auth_id": device_auth_id, "user_code": user_code }))
447            .send()
448            .await
449            .context("polling OpenAI Codex device login")?;
450        if resp.status().is_success() {
451            let v = resp
452                .json::<serde_json::Value>()
453                .await
454                .context("parsing OpenAI Codex device token")?;
455            let authorization_code = v
456                .get("authorization_code")
457                .and_then(|v| v.as_str())
458                .context("missing authorization_code")?
459                .to_string();
460            let code_verifier = v
461                .get("code_verifier")
462                .and_then(|v| v.as_str())
463                .context("missing code_verifier")?
464                .to_string();
465            break (authorization_code, code_verifier);
466        }
467        if resp.status().as_u16() != 403 && resp.status().as_u16() != 404 {
468            let status_code = resp.status();
469            let body = resp.text().await.unwrap_or_default();
470            bail!("OpenAI Codex device login failed ({status_code}): {body}");
471        }
472    };
473
474    let token = client
475        .post("https://auth.openai.com/oauth/token")
476        .header("Content-Type", "application/x-www-form-urlencoded")
477        .form(&[
478            ("grant_type", "authorization_code"),
479            ("client_id", "app_EMoamEEZ73f0CkXaXp7hrann"),
480            ("code", code.0.as_str()),
481            ("code_verifier", code.1.as_str()),
482            (
483                "redirect_uri",
484                "https://auth.openai.com/deviceauth/callback",
485            ),
486        ])
487        .send()
488        .await
489        .context("exchanging OpenAI Codex device code")?
490        .error_for_status()
491        .context("OpenAI Codex device code exchange failed")?
492        .json::<serde_json::Value>()
493        .await
494        .context("parsing OpenAI Codex token")?;
495    let access = token
496        .get("access_token")
497        .and_then(|v| v.as_str())
498        .context("missing access_token")?
499        .to_string();
500    let refresh = token
501        .get("refresh_token")
502        .and_then(|v| v.as_str())
503        .context("missing refresh_token")?
504        .to_string();
505    let expires_in = token
506        .get("expires_in")
507        .and_then(serde_json::Value::as_i64)
508        .context("missing expires_in")?;
509    let creds = CodexCredentials {
510        account_id: codex_account_id(&access)?,
511        access,
512        refresh,
513        expires: chrono::Utc::now().timestamp_millis() + expires_in * 1000,
514    };
515    save_codex_credentials(&creds)?;
516    Ok(creds)
517}
518
519fn write_provider_config(
520    openrouter_key: &str,
521    openai_key: &str,
522    opencode_key: &str,
523    codex: Option<&CodexCredentials>,
524) -> Result<()> {
525    let path = config_path()?;
526    if let Some(dir) = path.parent() {
527        std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
528    }
529    let escape = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
530    let mut body = format!(
531        "[provider]\n\
532         # OpenRouter key (or set ${OPENROUTER_ENV_KEY})\nopenrouter_key = \"{}\"\n\
533         # OpenAI API key (or set ${OPENAI_ENV_KEY})\nopenai_key = \"{}\"\n\
534         # OpenCode Go key (or set ${OPENCODE_ENV_KEY})\nopencode_key = \"{}\"\n",
535        escape(openrouter_key),
536        escape(openai_key),
537        escape(opencode_key),
538    );
539    if let Ok(Some(token)) = load_host_token() {
540        let _ = writeln!(body, "host_token = \"{}\"", escape(&token));
541    }
542    if let Some(c) = codex {
543        body.push_str("\n[provider.openai_codex]\n");
544        let _ = writeln!(body, "access = \"{}\"", escape(&c.access));
545        let _ = writeln!(body, "refresh = \"{}\"", escape(&c.refresh));
546        let _ = writeln!(body, "expires = {}", c.expires);
547        let _ = writeln!(body, "account_id = \"{}\"", escape(&c.account_id));
548    }
549    if let Ok(Some(tunnel)) = load_named_tunnel() {
550        body.push_str("\n[host.named_tunnel]\n");
551        let _ = writeln!(body, "tunnel_id = \"{}\"", escape(&tunnel.tunnel_id));
552        let _ = writeln!(body, "hostname = \"{}\"", escape(&tunnel.hostname));
553        let _ = writeln!(
554            body,
555            "credentials_path = \"{}\"",
556            escape(&tunnel.credentials_path.display().to_string())
557        );
558        let _ = writeln!(
559            body,
560            "config_path = \"{}\"",
561            escape(&tunnel.config_path.display().to_string())
562        );
563    }
564    std::fs::write(&path, body).with_context(|| format!("writing {}", path.display()))?;
565    Ok(())
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn parses_keys() {
574        let cfg: Config = toml::from_str(
575            "[provider]\nopenrouter_key = \"sk-or-abc\"\nopenai_key = \"sk-proj-abc\"\n",
576        )
577        .unwrap();
578        assert_eq!(cfg.provider.openrouter_key, "sk-or-abc");
579        assert_eq!(cfg.provider.openai_key, "sk-proj-abc");
580    }
581
582    #[test]
583    fn missing_keys_default_empty() {
584        let cfg: Config = toml::from_str("[provider]\n").unwrap();
585        assert!(cfg.provider.openrouter_key.is_empty());
586        assert!(cfg.provider.openai_key.is_empty());
587    }
588
589    fn codex_creds(access: &str) -> CodexCredentials {
590        CodexCredentials {
591            access: access.to_string(),
592            refresh: "r".to_string(),
593            expires: 0,
594            account_id: "a".to_string(),
595        }
596    }
597
598    #[test]
599    fn first_configured_follows_fixed_priority() {
600        let saved = SavedCreds {
601            openrouter_key: Some("sk-or-abc".into()),
602            openai_key: Some("sk-proj-abc".into()),
603            opencode_key: Some("oc-token".into()),
604            codex: Some(codex_creds("codex-token")),
605            ..Default::default()
606        };
607        assert_eq!(
608            first_configured(&saved),
609            Some(("openrouter", "sk-or-abc".to_string()))
610        );
611    }
612
613    #[test]
614    fn first_configured_falls_through_to_whatever_is_set() {
615        let saved = SavedCreds {
616            openrouter_key: None,
617            openai_key: None,
618            opencode_key: Some("oc-token".into()),
619            codex: Some(codex_creds("codex-token")),
620            ..Default::default()
621        };
622        assert_eq!(
623            first_configured(&saved),
624            Some(("opencode", "oc-token".to_string()))
625        );
626    }
627
628    #[test]
629    fn first_configured_none_when_nothing_saved() {
630        assert_eq!(first_configured(&SavedCreds::default()), None);
631    }
632}