Skip to main content

scv_server/
agents.rs

1//! Credentials for the native agents SCV delegates to, always inside SCV's
2//! private adapter homes: importing a user's own Codex or Grok setup, and the
3//! API-key and endpoint stores SCV writes in an agent CLI's native format.
4
5use std::io::{Read as _, Write as _};
6use std::path::Path;
7
8use anyhow::{Context, Result, anyhow, bail};
9use scv_tools::adapters::KeyStore;
10
11const MAX_IMPORT_BYTES: u64 = 1024 * 1024;
12
13enum Auth {
14    /// An API key, which is static and safe to hold in two homes.
15    ApiKey,
16    /// A ChatGPT sign-in, whose rotating refresh token must stay in one home.
17    Session,
18    None,
19}
20
21/// Copy `config.toml` and an API-key `auth.json` from a Codex home into
22/// `destination`, returning display lines that never contain secret values.
23/// Both files are validated before either is written.
24pub fn import_codex(source: &Path, destination: &Path) -> Result<Vec<String>> {
25    let source = std::fs::canonicalize(source)
26        .with_context(|| format!("resolve Codex home {}", source.display()))?;
27    let destination = std::fs::canonicalize(destination)
28        .with_context(|| format!("resolve SCV Codex home {}", destination.display()))?;
29    if source == destination {
30        bail!(
31            "{} is already SCV's Codex adapter home; pass your own Codex home with --from",
32            source.display()
33        );
34    }
35    let config = read_bounded(&source.join("config.toml"))?;
36    let auth = read_bounded(&source.join("auth.json"))?;
37    if config.is_none() && auth.is_none() {
38        bail!("no config.toml or auth.json in {}", source.display());
39    }
40    let table = config
41        .as_deref()
42        .map(|text| text.parse::<toml::Table>())
43        .transpose()
44        .context("parse Codex config.toml")?;
45    let auth_kind = auth.as_deref().map(classify_auth).transpose()?;
46
47    let mut notes = Vec::new();
48    if let (Some(text), Some(table)) = (&config, &table) {
49        write_private(&destination.join("config.toml"), text)?;
50        notes.push(format!("Copied config.toml{}", describe(table)));
51        notes.extend(config_notes(table));
52    }
53    match (&auth, auth_kind) {
54        (Some(text), Some(Auth::ApiKey)) => {
55            write_private(&destination.join("auth.json"), text)?;
56            notes.push("Copied the API-key sign-in from auth.json".into());
57        }
58        (Some(_), Some(Auth::Session)) => notes.push(
59            "Skipped auth.json: a ChatGPT sign-in's refresh token must not be shared; \
60             sign SCV in separately with `scv agents login codex`"
61                .into(),
62        ),
63        (Some(_), Some(Auth::None)) => notes.push("Skipped auth.json: it holds no API key".into()),
64        _ => {}
65    }
66    Ok(notes)
67}
68
69fn read_bounded(path: &Path) -> Result<Option<String>> {
70    let file = match std::fs::File::open(path) {
71        Ok(file) => file,
72        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
73        Err(error) => return Err(anyhow!(error).context(format!("open {}", path.display()))),
74    };
75    if !file.metadata()?.is_file() {
76        bail!("{} is not a regular file", path.display());
77    }
78    let mut text = String::new();
79    file.take(MAX_IMPORT_BYTES + 1)
80        .read_to_string(&mut text)
81        .with_context(|| format!("read {}", path.display()))?;
82    if text.len() as u64 > MAX_IMPORT_BYTES {
83        bail!("{} exceeds 1 MiB", path.display());
84    }
85    Ok(Some(text))
86}
87
88fn classify_auth(text: &str) -> Result<Auth> {
89    let value: serde_json::Value =
90        serde_json::from_str(text).map_err(|_| anyhow!("Codex auth.json is not valid JSON"))?;
91    let object = value
92        .as_object()
93        .ok_or_else(|| anyhow!("Codex auth.json is not a JSON object"))?;
94    if object.get("tokens").is_some_and(|tokens| !tokens.is_null()) {
95        return Ok(Auth::Session);
96    }
97    let has_key = object
98        .get("OPENAI_API_KEY")
99        .and_then(serde_json::Value::as_str)
100        .is_some_and(|key| !key.trim().is_empty());
101    Ok(if has_key { Auth::ApiKey } else { Auth::None })
102}
103
104/// Non-secret identifying settings, debug-quoted so they are terminal-safe.
105fn describe(table: &toml::Table) -> String {
106    let fields: Vec<String> = ["model_provider", "model"]
107        .into_iter()
108        .filter_map(|key| Some(format!("{key} {:?}", table.get(key)?.as_str()?)))
109        .collect();
110    if fields.is_empty() {
111        String::new()
112    } else {
113        format!(" ({})", fields.join(", "))
114    }
115}
116
117fn config_notes(table: &toml::Table) -> Vec<String> {
118    let mut notes = Vec::new();
119    let policy: Vec<String> = ["sandbox_mode", "approval_policy"]
120        .into_iter()
121        .filter_map(|key| Some(format!("{key} {:?}", table.get(key)?.as_str()?)))
122        .collect();
123    if !policy.is_empty() {
124        notes.push(format!(
125            "Delegated Codex runs also use {}",
126            policy.join(" and ")
127        ));
128    }
129    let providers = table
130        .get("model_providers")
131        .and_then(toml::Value::as_table)
132        .into_iter()
133        .flatten();
134    for (name, provider) in providers {
135        let Some(variable) = provider.get("env_key").and_then(toml::Value::as_str) else {
136            continue;
137        };
138        notes.push(
139            if scv_tools::adapters::is_removed_agent_variable(std::ffi::OsStr::new(variable)) {
140                format!(
141                    "Warning: provider {name:?} reads its key from ${variable}, which SCV \
142                     removes from delegated agents; keep the key in auth.json \
143                     (requires_openai_auth) or experimental_bearer_token instead"
144                )
145            } else {
146                format!(
147                    "Note: provider {name:?} reads its key from ${variable}; the SCV daemon's \
148                     environment must provide it (the user service does not load your shell profile)"
149                )
150            },
151        );
152    }
153    notes
154}
155
156/// Copy the user's Grok `config.toml` from `source` (a Grok home) into
157/// `destination` (SCV's Grok home), returning display lines that never
158/// contain secret values. Top-level tables from the user's file win; tables
159/// only SCV's copy has, such as the `[marketplace]` state Grok writes there,
160/// are kept. `auth.json` sign-ins are never copied. The merged file is
161/// validated before anything is written.
162pub fn import_grok(source: &Path, destination: &Path) -> Result<Vec<String>> {
163    let source = std::fs::canonicalize(source)
164        .with_context(|| format!("resolve Grok home {}", source.display()))?;
165    std::fs::create_dir_all(destination)
166        .with_context(|| format!("create {}", destination.display()))?;
167    let destination = std::fs::canonicalize(destination)
168        .with_context(|| format!("resolve SCV Grok home {}", destination.display()))?;
169    if source == destination {
170        bail!(
171            "{} is already SCV's Grok home; pass your own Grok home with --from",
172            source.display()
173        );
174    }
175    let text = read_bounded(&source.join("config.toml"))?
176        .ok_or_else(|| anyhow!("no config.toml in {}", source.display()))?;
177    // Never echo parse errors' source text: these files hold keys.
178    let user: toml::Table = text
179        .parse()
180        .map_err(|_| anyhow!("your Grok config.toml is not valid TOML"))?;
181    let target = destination.join("config.toml");
182    let existing: toml::Table = match read_bounded(&target)? {
183        Some(existing) => existing.parse().map_err(|_| {
184            anyhow!(
185                "SCV's Grok config.toml ({}) is not valid TOML; move it aside and import again",
186                target.display()
187            )
188        })?,
189        None => toml::Table::new(),
190    };
191    let kept: toml::Table = existing
192        .into_iter()
193        .filter(|(key, _)| !user.contains_key(key))
194        .collect();
195    let merged = if kept.values().all(toml::Value::is_table) {
196        // Appending whole tables keeps the user's own formatting and comments.
197        let mut merged = text.trim_end().to_owned();
198        merged.push('\n');
199        if !kept.is_empty() {
200            merged.push('\n');
201            merged.push_str(&toml::to_string(&kept).context("serialize kept settings")?);
202        }
203        merged
204    } else {
205        // A kept top-level value would land inside the user's last table if
206        // appended, so write the merged table instead.
207        let mut table = user.clone();
208        table.extend(kept.clone());
209        toml::to_string(&table).context("serialize merged Grok config")?
210    };
211    if merged.parse::<toml::Table>().is_err() {
212        bail!("the merged Grok config.toml would not be valid TOML; nothing was written");
213    }
214    write_private(&target, &merged)?;
215
216    let mut notes = vec![format!("Copied config.toml{}", describe_grok(&user))];
217    if !kept.is_empty() {
218        let names: Vec<String> = kept.keys().map(|key| format!("{key:?}")).collect();
219        notes.push(format!("Kept SCV-only settings: {}", names.join(", ")));
220    }
221    match grok_default_key(&user) {
222        GrokKey::InConfig(_) | GrokKey::NoDefault => {}
223        GrokKey::FromVariable(model, variable) => notes.push(grok_variable_note(&model, &variable)),
224        GrokKey::Missing(model) => notes.push(format!(
225            "Note: default model {model:?} has no api_key in config; sign SCV in with \
226             `scv agents login grok` or add api_key to its profile"
227        )),
228    }
229    if source.join("auth.json").exists() {
230        notes.push(
231            "Skipped auth.json: `grok login` sign-ins are not shared; sign SCV in \
232             separately with `scv agents login grok` if you need one"
233                .into(),
234        );
235    }
236    Ok(notes)
237}
238
239/// Profiles and the default model, debug-quoted so they are terminal-safe.
240fn describe_grok(table: &toml::Table) -> String {
241    let profiles: Vec<String> = table
242        .get("model")
243        .and_then(toml::Value::as_table)
244        .map(|models| models.keys().map(|key| format!("{key:?}")).collect())
245        .unwrap_or_default();
246    let default =
247        grok_default(table).map_or_else(|| "built-in".into(), |model| format!("{model:?}"));
248    if profiles.is_empty() {
249        format!(" (default model {default}; no model profiles)")
250    } else {
251        format!(
252            " (default model {default}; profiles {})",
253            profiles.join(", ")
254        )
255    }
256}
257
258/// Where the key for Grok's default model comes from.
259enum GrokKey {
260    /// No `[models] default`: Grok's built-in default needs `grok login`.
261    NoDefault,
262    /// The default's profile holds an `api_key`.
263    InConfig(String),
264    /// The default's profile reads its key from these variables.
265    FromVariable(String, Vec<String>),
266    /// The default has no profile key.
267    Missing(String),
268}
269
270fn grok_default(table: &toml::Table) -> Option<String> {
271    table
272        .get("models")?
273        .get("default")?
274        .as_str()
275        .map(ToOwned::to_owned)
276}
277
278/// Resolve the default model's profile, by catalog key or by model id as
279/// Grok does, and say where its key comes from.
280fn grok_default_key(table: &toml::Table) -> GrokKey {
281    let Some(default) = grok_default(table) else {
282        return GrokKey::NoDefault;
283    };
284    let models = table.get("model").and_then(toml::Value::as_table);
285    let profile = models.and_then(|models| {
286        models.get(&default).or_else(|| {
287            models.values().find(|profile| {
288                profile.get("model").and_then(toml::Value::as_str) == Some(&default)
289            })
290        })
291    });
292    let Some(profile) = profile else {
293        return GrokKey::Missing(default);
294    };
295    if profile
296        .get("api_key")
297        .and_then(toml::Value::as_str)
298        .is_some_and(|key| !key.trim().is_empty())
299    {
300        return GrokKey::InConfig(default);
301    }
302    let variables: Vec<String> = match profile.get("env_key") {
303        Some(toml::Value::String(name)) => vec![name.clone()],
304        Some(toml::Value::Array(names)) => names
305            .iter()
306            .filter_map(toml::Value::as_str)
307            .map(ToOwned::to_owned)
308            .collect(),
309        _ => Vec::new(),
310    };
311    if variables.is_empty() {
312        GrokKey::Missing(default)
313    } else {
314        GrokKey::FromVariable(default, variables)
315    }
316}
317
318/// A variable Grok can read in a delegated run: set here and not one SCV
319/// removes from delegated agents.
320fn usable_grok_variable(variables: &[String]) -> Option<&String> {
321    variables.iter().find(|variable| {
322        !scv_tools::adapters::is_removed_agent_variable(std::ffi::OsStr::new(variable.as_str()))
323            && std::env::var_os(variable).is_some_and(|value| !value.is_empty())
324    })
325}
326
327fn grok_variable_note(model: &str, variables: &[String]) -> String {
328    let names: Vec<String> = variables.iter().map(|name| format!("${name}")).collect();
329    format!(
330        "Note: default model {model:?} reads its key from {}; SCV removes key variables \
331         from delegated agents and its service does not load your shell profile, so put \
332         api_key in the profile instead",
333        names.join(" or ")
334    )
335}
336
337fn grok_status(auth: &Path, config: &Path, home: &Path) -> Result<(bool, Vec<String>)> {
338    let entries = read_json_object(auth)?
339        .map(|object| object.values().filter(|value| !value.is_null()).count())
340        .unwrap_or(0);
341    if entries > 0 {
342        return Ok((true, vec![format!("signed in ({})", display(auth, home))]));
343    }
344    let Some(text) = read_bounded(config)? else {
345        return Ok((false, vec!["not signed in".into()]));
346    };
347    let table: toml::Table = text
348        .parse()
349        .map_err(|_| anyhow!("{} is not valid TOML", display(config, home)))?;
350    Ok(match grok_default_key(&table) {
351        GrokKey::InConfig(model) => (
352            true,
353            vec![format!("signed in (API key in config, model {model:?})")],
354        ),
355        GrokKey::FromVariable(model, variables) => match usable_grok_variable(&variables) {
356            Some(variable) => (
357                true,
358                vec![format!("signed in (key from ${variable}, model {model:?})")],
359            ),
360            None => (
361                false,
362                vec![
363                    "not signed in".into(),
364                    grok_variable_note(&model, &variables),
365                ],
366            ),
367        },
368        GrokKey::Missing(model) => (
369            false,
370            vec![
371                "not signed in".into(),
372                format!("default model {model:?} has no api_key in config"),
373            ],
374        ),
375        GrokKey::NoDefault => (false, vec!["not signed in".into()]),
376    })
377}
378
379fn write_private(path: &Path, contents: &str) -> Result<()> {
380    let parent = path
381        .parent()
382        .ok_or_else(|| anyhow!("import path has no parent"))?;
383    // Named temporary files are created with mode 0600.
384    let mut temporary =
385        tempfile::NamedTempFile::new_in(parent).context("create temporary import file")?;
386    temporary
387        .write_all(contents.as_bytes())
388        .context("write imported file")?;
389    temporary.as_file().sync_all()?;
390    temporary
391        .persist(path)
392        .map_err(|error| anyhow!("replace {}: {}", path.display(), error.error))?;
393    Ok(())
394}
395
396/// Longest API key or endpoint field SCV accepts.
397const MAX_FIELD_BYTES: usize = 4096;
398
399/// The pi provider id SCV writes for an OpenAI-compatible endpoint.
400pub const PI_PROVIDER: &str = "scv";
401
402/// Which OpenAI wire protocol an endpoint speaks.
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404pub enum WireApi {
405    Responses,
406    ChatCompletions,
407}
408
409impl WireApi {
410    fn pi_api(self) -> &'static str {
411        match self {
412            Self::Responses => "openai-responses",
413            Self::ChatCompletions => "openai-completions",
414        }
415    }
416}
417
418/// An OpenAI-compatible endpoint for pi, without its key.
419#[derive(Debug, Clone)]
420pub struct Endpoint {
421    pub base_url: String,
422    pub api: WireApi,
423    pub model: String,
424}
425
426/// Read a secret from the terminal without echo, or from piped stdin.
427pub fn read_secret(prompt: &str) -> Result<String> {
428    use std::io::{BufRead as _, IsTerminal as _};
429    let stdin = std::io::stdin();
430    let mut line = String::new();
431    if stdin.is_terminal() {
432        eprint!("{prompt}: ");
433        std::io::stderr().flush().ok();
434        let _echo = EchoOff::new()?;
435        stdin.lock().read_line(&mut line)?;
436        eprintln!();
437    } else {
438        stdin
439            .lock()
440            .take(MAX_FIELD_BYTES as u64 + 2)
441            .read_line(&mut line)?;
442    }
443    let secret = line.trim().to_owned();
444    validate_secret(&secret)?;
445    Ok(secret)
446}
447
448/// Terminal echo disabled for the guard's lifetime.
449struct EchoOff(libc::termios);
450
451impl EchoOff {
452    fn new() -> Result<Self> {
453        let mut termios = std::mem::MaybeUninit::<libc::termios>::uninit();
454        // SAFETY: tcgetattr fills the termios struct for a valid descriptor.
455        if unsafe { libc::tcgetattr(libc::STDIN_FILENO, termios.as_mut_ptr()) } != 0 {
456            bail!(
457                "read terminal settings: {}",
458                std::io::Error::last_os_error()
459            );
460        }
461        // SAFETY: tcgetattr succeeded, so the struct is initialized.
462        let original = unsafe { termios.assume_init() };
463        let mut silent = original;
464        silent.c_lflag &= !libc::ECHO;
465        // SAFETY: a valid descriptor and a termios derived from its own settings.
466        if unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &silent) } != 0 {
467            bail!("disable terminal echo: {}", std::io::Error::last_os_error());
468        }
469        Ok(Self(original))
470    }
471}
472
473impl Drop for EchoOff {
474    fn drop(&mut self) {
475        // SAFETY: restores the settings read from the same descriptor.
476        unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &self.0) };
477    }
478}
479
480fn validate_secret(secret: &str) -> Result<()> {
481    if secret.is_empty() {
482        bail!("no key entered");
483    }
484    if secret.len() > MAX_FIELD_BYTES
485        || secret
486            .chars()
487            .any(|c| c.is_whitespace() || c.is_control() || c == '"' || c == '\\')
488    {
489        bail!("the key must be one line of at most {MAX_FIELD_BYTES} printable characters");
490    }
491    Ok(())
492}
493
494/// Store an API key in `store`, inside the adapter `home`.
495pub fn store_key(store: KeyStore, home: &Path, key: &str) -> Result<Vec<String>> {
496    validate_secret(key)?;
497    match store {
498        KeyStore::DshRefs { path, variable } => {
499            let path = home.join(path);
500            create_private_dirs(home, &path)?;
501            // serde_json quoting is a valid YAML double-quoted scalar.
502            let quoted = serde_json::to_string(key)?;
503            write_private(
504                &path,
505                &format!("version: 1\n\nrefs:\n  {variable}: {quoted}\n"),
506            )?;
507            Ok(vec![format!(
508                "Stored the API key as {variable} in {}",
509                display(&path, home)
510            )])
511        }
512        KeyStore::Grok { .. } | KeyStore::Pi { .. } => {
513            bail!("this agent signs in with its own login, not a stored key")
514        }
515    }
516}
517
518/// Describe what `store` holds, never printing a secret.
519pub fn stored_status(store: KeyStore, home: &Path) -> Result<(bool, Vec<String>)> {
520    match store {
521        KeyStore::Grok { auth, config } => grok_status(&home.join(auth), &home.join(config), home),
522        KeyStore::DshRefs { path, variable } => {
523            let path = home.join(path);
524            let stored = read_bounded(&path)?.is_some_and(|text| {
525                text.lines().any(|line| {
526                    line.trim_start()
527                        .strip_prefix(variable)
528                        .and_then(|rest| rest.strip_prefix(':'))
529                        .is_some_and(|value| !matches!(value.trim(), "" | "\"\"" | "''"))
530                })
531            });
532            Ok(if stored {
533                (true, vec![format!("API key stored as {variable}")])
534            } else {
535                (false, vec!["not signed in".into()])
536            })
537        }
538        KeyStore::Pi { dir } => pi_status(&home.join(dir)),
539    }
540}
541
542/// Remove the credentials SCV can see in `store`.
543pub fn remove_stored(store: KeyStore, home: &Path) -> Result<Vec<String>> {
544    match store {
545        KeyStore::Grok { auth: path, .. } | KeyStore::DshRefs { path, .. } => {
546            let path = home.join(path);
547            Ok(vec![if remove_if_present(&path)? {
548                format!("Removed {}", display(&path, home))
549            } else {
550                "Nothing stored".into()
551            }])
552        }
553        KeyStore::Pi { dir } => {
554            let dir = home.join(dir);
555            let mut notes = Vec::new();
556            if remove_if_present(&dir.join("auth.json"))? {
557                notes.push("Removed pi's stored sign-ins (auth.json)".into());
558            }
559            let models = dir.join("models.json");
560            if let Some(mut object) = read_json_object(&models)?
561                && let Some(providers) = object
562                    .get_mut("providers")
563                    .and_then(serde_json::Value::as_object_mut)
564                && providers.remove(PI_PROVIDER).is_some()
565            {
566                write_json(&models, &object)?;
567                notes.push(format!(
568                    "Removed the {PI_PROVIDER} endpoint from models.json"
569                ));
570            }
571            let settings = dir.join("settings.json");
572            if let Some(mut object) = read_json_object(&settings)?
573                && object
574                    .get("defaultProvider")
575                    .and_then(serde_json::Value::as_str)
576                    == Some(PI_PROVIDER)
577            {
578                object.remove("defaultProvider");
579                object.remove("defaultModel");
580                write_json(&settings, &object)?;
581                notes.push("Cleared pi's default model".into());
582            }
583            if notes.is_empty() {
584                notes.push("Nothing stored".into());
585            }
586            Ok(notes)
587        }
588    }
589}
590
591/// Point pi at an OpenAI-compatible endpoint as provider [`PI_PROVIDER`]
592/// and make it pi's default, storing the key in pi's `auth.json`.
593pub fn configure_pi_endpoint(dir: &Path, endpoint: &Endpoint, key: &str) -> Result<Vec<String>> {
594    validate_secret(key)?;
595    let base_url = validate_base_url(&endpoint.base_url)?;
596    if !valid_model_id(&endpoint.model) {
597        bail!("invalid model id {:?}", endpoint.model);
598    }
599    create_private_dirs(dir, &dir.join("auth.json"))?;
600    // Validate every existing file before changing any of them.
601    let mut models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
602    let mut auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
603    let mut settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
604
605    let providers = models
606        .entry("providers")
607        .or_insert_with(|| serde_json::json!({}));
608    let providers = providers
609        .as_object_mut()
610        .ok_or_else(|| anyhow!("pi models.json has a non-object \"providers\""))?;
611    let mut provider = serde_json::json!({
612        "baseUrl": base_url,
613        "api": endpoint.api.pi_api(),
614        "models": [{"id": endpoint.model}],
615    });
616    if endpoint.api == WireApi::Responses {
617        // pi's default OpenAI affinity header is `session_id`; proxies that
618        // reject underscores in header names answer it with HTTP 520
619        // (verified against a relay). `x-client-request-id` still goes out.
620        provider["compat"] = serde_json::json!({"sessionAffinityFormat": "openai-nosession"});
621    }
622    providers.insert(PI_PROVIDER.into(), provider);
623    auth.insert(
624        PI_PROVIDER.into(),
625        serde_json::json!({"type": "api_key", "key": key}),
626    );
627    settings.insert("defaultProvider".into(), PI_PROVIDER.into());
628    settings.insert("defaultModel".into(), endpoint.model.clone().into());
629
630    write_json(&dir.join("models.json"), &models)?;
631    write_json(&dir.join("auth.json"), &auth)?;
632    write_json(&dir.join("settings.json"), &settings)?;
633    Ok(vec![
634        format!(
635            "Configured pi provider {PI_PROVIDER:?}: {} at {}, model {:?}",
636            endpoint.api.pi_api(),
637            host(&base_url),
638            endpoint.model
639        ),
640        "Stored its API key in pi's auth.json (mode 0600) and made it pi's default".into(),
641    ])
642}
643
644fn pi_status(dir: &Path) -> Result<(bool, Vec<String>)> {
645    let settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
646    let auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
647    let models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
648    let mut lines = Vec::new();
649    let text = |object: &serde_json::Map<String, serde_json::Value>, key: &str| {
650        object
651            .get(key)
652            .and_then(serde_json::Value::as_str)
653            .map(ToOwned::to_owned)
654    };
655    let provider = text(&settings, "defaultProvider");
656    if let Some(provider) = &provider {
657        let endpoint = models
658            .get("providers")
659            .and_then(|providers| providers.get(provider))
660            .and_then(serde_json::Value::as_object);
661        let location = endpoint
662            .map(|endpoint| {
663                format!(
664                    " ({} at {})",
665                    text(endpoint, "api").unwrap_or_else(|| "api unset".into()),
666                    text(endpoint, "baseUrl")
667                        .map_or_else(|| "no base URL".into(), |url| host(&url))
668                )
669            })
670            .unwrap_or_default();
671        lines.push(format!(
672            "default provider {provider:?}{location}, model {:?}",
673            text(&settings, "defaultModel").unwrap_or_else(|| "unset".into())
674        ));
675    }
676    let mut signed_in: Vec<&String> = auth.keys().collect();
677    signed_in.sort();
678    let ready = !signed_in.is_empty();
679    if ready {
680        let names: Vec<String> = signed_in.iter().map(|name| format!("{name:?}")).collect();
681        lines.push(format!("stored sign-ins: {}", names.join(", ")));
682    } else {
683        lines.push("not signed in".into());
684    }
685    Ok((ready, lines))
686}
687
688fn validate_base_url(value: &str) -> Result<String> {
689    let value = value.trim().trim_end_matches('/');
690    let rest = value
691        .strip_prefix("https://")
692        .or_else(|| value.strip_prefix("http://"))
693        .ok_or_else(|| anyhow!("the base URL must start with https:// or http://"))?;
694    let authority = rest.split('/').next().unwrap_or_default();
695    if authority.is_empty()
696        || authority.contains('@')
697        || value.len() > MAX_FIELD_BYTES
698        || value
699            .chars()
700            .any(|c| c.is_whitespace() || c.is_control() || matches!(c, '?' | '#'))
701    {
702        bail!("the base URL must be a plain http(s) URL without credentials, query, or fragment");
703    }
704    Ok(value.to_owned())
705}
706
707/// The host of a validated URL, for display.
708fn host(url: &str) -> String {
709    url.split("://")
710        .nth(1)
711        .and_then(|rest| rest.split('/').next())
712        .unwrap_or(url)
713        .to_owned()
714}
715
716fn valid_model_id(model: &str) -> bool {
717    !model.is_empty()
718        && model.len() <= 128
719        && !model.starts_with(['-', '@'])
720        && model
721            .chars()
722            .all(|c| c.is_ascii_alphanumeric() || "._:/@[]-".contains(c))
723}
724
725fn display(path: &Path, home: &Path) -> String {
726    path.strip_prefix(home).map_or_else(
727        |_| path.display().to_string(),
728        |relative| relative.display().to_string(),
729    )
730}
731
732fn read_json_object(path: &Path) -> Result<Option<serde_json::Map<String, serde_json::Value>>> {
733    let Some(text) = read_bounded(path)? else {
734        return Ok(None);
735    };
736    if text.trim().is_empty() {
737        return Ok(Some(serde_json::Map::new()));
738    }
739    match serde_json::from_str(&text) {
740        Ok(serde_json::Value::Object(object)) => Ok(Some(object)),
741        // Never echo the content: these files hold keys.
742        _ => bail!("{} is not a JSON object", path.display()),
743    }
744}
745
746fn write_json(path: &Path, object: &serde_json::Map<String, serde_json::Value>) -> Result<()> {
747    write_private(
748        path,
749        &format!("{}\n", serde_json::to_string_pretty(object)?),
750    )
751}
752
753fn remove_if_present(path: &Path) -> Result<bool> {
754    match std::fs::remove_file(path) {
755        Ok(()) => Ok(true),
756        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
757        Err(error) => Err(anyhow!(error).context(format!("remove {}", path.display()))),
758    }
759}
760
761/// Create the directories between `root` and `file` with mode 0700.
762fn create_private_dirs(root: &Path, file: &Path) -> Result<()> {
763    let parent = file
764        .parent()
765        .ok_or_else(|| anyhow!("{} has no parent", file.display()))?;
766    std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
767    use std::os::unix::fs::PermissionsExt as _;
768    let mut dir = parent;
769    loop {
770        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
771        match dir.parent() {
772            Some(next) if next.starts_with(root) && next != root => dir = next,
773            _ => break,
774        }
775    }
776    Ok(())
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782
783    const CONFIG: &str = r#"model_provider = "relay"
784model = "gpt-test"
785sandbox_mode = "danger-full-access"
786approval_policy = "never"
787
788[model_providers.relay]
789base_url = "https://relay.invalid"
790wire_api = "responses"
791requires_openai_auth = true
792experimental_bearer_token = "sk-bearer-secret"
793"#;
794
795    fn homes() -> (tempfile::TempDir, tempfile::TempDir) {
796        (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap())
797    }
798
799    fn mode(path: &Path) -> u32 {
800        use std::os::unix::fs::PermissionsExt;
801        std::fs::metadata(path).unwrap().permissions().mode() & 0o777
802    }
803
804    #[test]
805    fn copies_config_and_api_key_privately_without_printing_secrets() {
806        let (source, destination) = homes();
807        std::fs::write(source.path().join("config.toml"), CONFIG).unwrap();
808        let auth = r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-auth-secret"}"#;
809        std::fs::write(source.path().join("auth.json"), auth).unwrap();
810        let notes = import_codex(source.path(), destination.path()).unwrap();
811        for file in ["config.toml", "auth.json"] {
812            let copied = destination.path().join(file);
813            assert_eq!(
814                std::fs::read_to_string(&copied).unwrap(),
815                std::fs::read_to_string(source.path().join(file)).unwrap()
816            );
817            assert_eq!(mode(&copied), 0o600);
818        }
819        let notes = notes.join("\n");
820        assert!(notes.contains(r#"model_provider "relay", model "gpt-test""#));
821        assert!(notes.contains(r#"sandbox_mode "danger-full-access" and approval_policy "never""#));
822        assert!(notes.contains("API-key sign-in"));
823        assert!(!notes.contains("secret"));
824    }
825
826    #[test]
827    fn never_copies_a_chatgpt_session() {
828        let (source, destination) = homes();
829        std::fs::write(source.path().join("config.toml"), "model = \"m\"\n").unwrap();
830        std::fs::write(
831            source.path().join("auth.json"),
832            r#"{"auth_mode":"chatgpt","OPENAI_API_KEY":null,"tokens":{"refresh_token":"rt"}}"#,
833        )
834        .unwrap();
835        let notes = import_codex(source.path(), destination.path()).unwrap();
836        assert!(destination.path().join("config.toml").is_file());
837        assert!(!destination.path().join("auth.json").exists());
838        assert!(notes.join("\n").contains("scv agents login codex"));
839    }
840
841    #[test]
842    fn env_key_providers_are_flagged() {
843        let (source, destination) = homes();
844        std::fs::write(
845            source.path().join("config.toml"),
846            "[model_providers.a]\nenv_key = \"OPENAI_API_KEY\"\n\
847             [model_providers.b]\nenv_key = \"RELAY_KEY\"\n",
848        )
849        .unwrap();
850        let notes = import_codex(source.path(), destination.path())
851            .unwrap()
852            .join("\n");
853        assert!(notes.contains("$OPENAI_API_KEY, which SCV removes"));
854        assert!(notes.contains("$RELAY_KEY; the SCV daemon's environment must provide it"));
855    }
856
857    const DSH: KeyStore = KeyStore::DshRefs {
858        path: ".dsh/.credentials.yaml",
859        variable: "DEEPSEEK_API_KEY",
860    };
861    const PI: KeyStore = KeyStore::Pi { dir: ".pi/agent" };
862    const GROK: KeyStore = KeyStore::Grok {
863        auth: ".grok/auth.json",
864        config: ".grok/config.toml",
865    };
866
867    const GROK_CONFIG: &str = r#"[cli]
868installer = "internal"
869
870# The relay profile.
871[model.relay]
872model = "grok-4.5"
873base_url = "https://relay.invalid"
874api_key = "xai-profile-secret"
875api_backend = "responses"
876
877[model."relay-4.7"]
878model = "grok-4.7"
879base_url = "https://relay.invalid"
880api_key = "xai-profile-secret"
881
882[models]
883default = "relay-4.7"
884"#;
885
886    fn grok_status_of(config: &str) -> (bool, String) {
887        let home = tempfile::tempdir().unwrap();
888        std::fs::create_dir(home.path().join(".grok")).unwrap();
889        std::fs::write(home.path().join(".grok/config.toml"), config).unwrap();
890        let (ready, lines) = stored_status(GROK, home.path()).unwrap();
891        (ready, lines.join("\n"))
892    }
893
894    #[test]
895    fn grok_config_keys_count_as_signed_in_without_printing_them() {
896        let (ready, lines) = grok_status_of(GROK_CONFIG);
897        assert!(ready);
898        assert_eq!(lines, r#"signed in (API key in config, model "relay-4.7")"#);
899        // The default may name a model id instead of a catalog key.
900        let (ready, _) = grok_status_of(
901            &GROK_CONFIG.replace(r#"default = "relay-4.7""#, r#"default = "grok-4.5""#),
902        );
903        assert!(ready);
904        // A default without a key, an unknown default, and no default at all.
905        let keyless = "[model.m]\nmodel = \"grok-4.7\"\n[models]\ndefault = \"m\"\n";
906        let (ready, lines) = grok_status_of(keyless);
907        assert!(!ready);
908        assert!(lines.contains(r#"default model "m" has no api_key in config"#));
909        assert!(!grok_status_of("[models]\ndefault = \"grok-9\"\n").0);
910        assert!(!grok_status_of("[cli]\ninstaller = \"internal\"\n").0);
911        // A key variable SCV removes from delegated agents does not count.
912        let removed = "[model.m]\nenv_key = [\"XAI_API_KEY\"]\n[models]\ndefault = \"m\"\n";
913        let (ready, lines) = grok_status_of(removed);
914        assert!(!ready);
915        assert!(lines.contains("$XAI_API_KEY"), "{lines}");
916        // An unparsable config is an error that never echoes its content.
917        let home = tempfile::tempdir().unwrap();
918        std::fs::create_dir(home.path().join(".grok")).unwrap();
919        std::fs::write(
920            home.path().join(".grok/config.toml"),
921            "api_key = xai-secret",
922        )
923        .unwrap();
924        let error = stored_status(GROK, home.path()).unwrap_err().to_string();
925        assert!(!error.contains("secret"), "{error}");
926    }
927
928    #[test]
929    fn grok_import_merges_by_table_privately_without_printing_keys() {
930        let (source, scv) = homes();
931        let destination = scv.path().join(".grok");
932        std::fs::create_dir(&destination).unwrap();
933        std::fs::write(source.path().join("config.toml"), GROK_CONFIG).unwrap();
934        std::fs::write(
935            source.path().join("auth.json"),
936            r#"{"a":{"key":"xai-token-secret"}}"#,
937        )
938        .unwrap();
939        // Grok's own state in SCV's copy survives; a stale user table does not.
940        std::fs::write(
941            destination.join("config.toml"),
942            "[marketplace]\ndefault_skills_installs_purged = true\n\n[cli]\ninstaller = \"old\"\n",
943        )
944        .unwrap();
945        let notes = import_grok(source.path(), &destination).unwrap().join("\n");
946        let copied = destination.join("config.toml");
947        assert_eq!(mode(&copied), 0o600);
948        let text = std::fs::read_to_string(&copied).unwrap();
949        assert!(text.starts_with(GROK_CONFIG), "user formatting is kept");
950        let table: toml::Table = text.parse().unwrap();
951        assert_eq!(table["cli"]["installer"].as_str(), Some("internal"));
952        assert_eq!(
953            table["marketplace"]["default_skills_installs_purged"].as_bool(),
954            Some(true)
955        );
956        assert_eq!(table["models"]["default"].as_str(), Some("relay-4.7"));
957        assert!(!destination.join("auth.json").exists());
958        assert!(notes.contains(r#"default model "relay-4.7"; profiles "relay", "relay-4.7""#));
959        assert!(notes.contains(r#"Kept SCV-only settings: "marketplace""#));
960        assert!(notes.contains("Skipped auth.json"));
961        assert!(!notes.contains("secret"), "{notes}");
962        assert!(stored_status(GROK, scv.path()).unwrap().0);
963    }
964
965    #[test]
966    fn grok_import_writes_nothing_when_either_file_is_invalid() {
967        let (source, scv) = homes();
968        let destination = scv.path().join(".grok");
969        std::fs::create_dir(&destination).unwrap();
970        let existing = "[marketplace]\nkept = true\n";
971        std::fs::write(destination.join("config.toml"), existing).unwrap();
972        std::fs::write(source.path().join("config.toml"), "api_key = xai-secret").unwrap();
973        let error = import_grok(source.path(), &destination)
974            .unwrap_err()
975            .to_string();
976        assert!(!error.contains("secret"), "{error}");
977        assert_eq!(
978            std::fs::read_to_string(destination.join("config.toml")).unwrap(),
979            existing
980        );
981        std::fs::write(source.path().join("config.toml"), GROK_CONFIG).unwrap();
982        std::fs::write(destination.join("config.toml"), "broken = [").unwrap();
983        assert!(import_grok(source.path(), &destination).is_err());
984        assert_eq!(
985            std::fs::read_to_string(destination.join("config.toml")).unwrap(),
986            "broken = ["
987        );
988        assert!(import_grok(&destination, &destination).is_err());
989        let empty = tempfile::tempdir().unwrap();
990        assert!(import_grok(empty.path(), &destination).is_err());
991    }
992
993    #[test]
994    fn grok_import_keeps_top_level_values_outside_the_users_tables() {
995        let (source, scv) = homes();
996        let destination = scv.path().join(".grok");
997        std::fs::create_dir(&destination).unwrap();
998        std::fs::write(source.path().join("config.toml"), GROK_CONFIG).unwrap();
999        std::fs::write(destination.join("config.toml"), "version = 3\n").unwrap();
1000        import_grok(source.path(), &destination).unwrap();
1001        let table: toml::Table = std::fs::read_to_string(destination.join("config.toml"))
1002            .unwrap()
1003            .parse()
1004            .unwrap();
1005        assert_eq!(table["version"].as_integer(), Some(3));
1006        assert!(table["models"].get("version").is_none());
1007        assert_eq!(table["models"]["default"].as_str(), Some("relay-4.7"));
1008    }
1009
1010    fn endpoint() -> Endpoint {
1011        Endpoint {
1012            base_url: "https://relay.invalid/v1/".into(),
1013            api: WireApi::Responses,
1014            model: "gpt-test".into(),
1015        }
1016    }
1017
1018    #[test]
1019    fn dsh_keys_are_stored_privately_in_its_native_file() {
1020        let home = tempfile::tempdir().unwrap();
1021        assert!(!stored_status(DSH, home.path()).unwrap().0);
1022        let notes = store_key(DSH, home.path(), "sk-dsh-secret").unwrap();
1023        assert!(!notes.join("\n").contains("secret"));
1024        let path = home.path().join(".dsh/.credentials.yaml");
1025        assert_eq!(
1026            std::fs::read_to_string(&path).unwrap(),
1027            "version: 1\n\nrefs:\n  DEEPSEEK_API_KEY: \"sk-dsh-secret\"\n"
1028        );
1029        assert_eq!(mode(&path), 0o600);
1030        assert_eq!(mode(&home.path().join(".dsh")), 0o700);
1031        let (ready, lines) = stored_status(DSH, home.path()).unwrap();
1032        assert!(ready);
1033        assert!(!lines.join("\n").contains("secret"));
1034        for invalid in ["", "two words", "quote\"d", "line\nbreak"] {
1035            assert!(store_key(DSH, home.path(), invalid).is_err(), "{invalid:?}");
1036        }
1037        assert_eq!(
1038            remove_stored(DSH, home.path()).unwrap(),
1039            ["Removed .dsh/.credentials.yaml"]
1040        );
1041        assert!(!path.exists());
1042        assert!(!stored_status(DSH, home.path()).unwrap().0);
1043    }
1044
1045    #[test]
1046    fn grok_login_entries_report_sign_in_without_values() {
1047        let home = tempfile::tempdir().unwrap();
1048        let store = GROK;
1049        assert!(!stored_status(store, home.path()).unwrap().0);
1050        std::fs::create_dir(home.path().join(".grok")).unwrap();
1051        std::fs::write(home.path().join(".grok/auth.json"), "{}").unwrap();
1052        assert!(!stored_status(store, home.path()).unwrap().0);
1053        std::fs::write(
1054            home.path().join(".grok/auth.json"),
1055            r#"{"https://auth.x.ai::id":{"key":"xai-token-secret"}}"#,
1056        )
1057        .unwrap();
1058        let (ready, lines) = stored_status(store, home.path()).unwrap();
1059        assert!(ready);
1060        assert!(!lines.join("\n").contains("secret"));
1061        std::fs::write(home.path().join(".grok/auth.json"), "xai-token-secret").unwrap();
1062        let error = stored_status(store, home.path()).unwrap_err().to_string();
1063        assert!(!error.contains("secret"), "{error}");
1064    }
1065
1066    #[test]
1067    fn pi_endpoints_merge_into_pi_files_as_the_private_default() {
1068        let home = tempfile::tempdir().unwrap();
1069        let dir = home.path().join(".pi/agent");
1070        std::fs::create_dir_all(&dir).unwrap();
1071        std::fs::write(
1072            dir.join("models.json"),
1073            r#"{"providers":{"ollama":{"baseUrl":"http://localhost:11434/v1","api":"openai-completions","models":[{"id":"q"}]}}}"#,
1074        )
1075        .unwrap();
1076        std::fs::write(dir.join("settings.json"), r#"{"theme":"dark"}"#).unwrap();
1077        let notes = configure_pi_endpoint(&dir, &endpoint(), "sk-pi-secret").unwrap();
1078        let notes = notes.join("\n");
1079        assert!(
1080            notes.contains("openai-responses at relay.invalid"),
1081            "{notes}"
1082        );
1083        assert!(!notes.contains("secret"));
1084
1085        let read = |file: &str| -> serde_json::Value {
1086            let path = dir.join(file);
1087            assert_eq!(mode(&path), 0o600, "{file}");
1088            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
1089        };
1090        let models = read("models.json");
1091        assert_eq!(
1092            models["providers"]["scv"],
1093            serde_json::json!({
1094                "baseUrl": "https://relay.invalid/v1",
1095                "api": "openai-responses",
1096                "models": [{"id": "gpt-test"}],
1097                "compat": {"sessionAffinityFormat": "openai-nosession"},
1098            })
1099        );
1100        assert_eq!(models["providers"]["ollama"]["models"][0]["id"], "q");
1101        assert_eq!(
1102            read("auth.json")["scv"],
1103            serde_json::json!({"type": "api_key", "key": "sk-pi-secret"})
1104        );
1105        let settings = read("settings.json");
1106        assert_eq!(settings["defaultProvider"], "scv");
1107        assert_eq!(settings["defaultModel"], "gpt-test");
1108        assert_eq!(settings["theme"], "dark");
1109
1110        let (ready, lines) = stored_status(PI, home.path()).unwrap();
1111        let lines = lines.join("\n");
1112        assert!(ready);
1113        assert!(
1114            lines.contains(
1115                r#"default provider "scv" (openai-responses at relay.invalid), model "gpt-test""#
1116            ),
1117            "{lines}"
1118        );
1119        assert!(!lines.contains("secret"));
1120
1121        let removed = remove_stored(PI, home.path()).unwrap().join("\n");
1122        assert!(removed.contains("auth.json"), "{removed}");
1123        assert!(!dir.join("auth.json").exists());
1124        let models = read("models.json");
1125        assert!(models["providers"].get("scv").is_none());
1126        assert!(models["providers"].get("ollama").is_some());
1127        let settings = read("settings.json");
1128        assert!(settings.get("defaultProvider").is_none());
1129        assert_eq!(settings["theme"], "dark");
1130        assert!(!stored_status(PI, home.path()).unwrap().0);
1131    }
1132
1133    #[test]
1134    fn pi_endpoint_input_is_validated_before_any_write() {
1135        let home = tempfile::tempdir().unwrap();
1136        let dir = home.path().join(".pi/agent");
1137        for base_url in [
1138            "ftp://relay.invalid",
1139            "https://user:pass@relay.invalid",
1140            "https://relay.invalid/v1?key=x",
1141            "https://",
1142        ] {
1143            let endpoint = Endpoint {
1144                base_url: base_url.into(),
1145                ..endpoint()
1146            };
1147            assert!(
1148                configure_pi_endpoint(&dir, &endpoint, "sk").is_err(),
1149                "{base_url}"
1150            );
1151        }
1152        let endpoint = Endpoint {
1153            model: "--flag".into(),
1154            ..endpoint()
1155        };
1156        assert!(configure_pi_endpoint(&dir, &endpoint, "sk").is_err());
1157        assert!(configure_pi_endpoint(&dir, &super::tests::endpoint(), "").is_err());
1158        assert!(!dir.exists());
1159
1160        std::fs::create_dir_all(&dir).unwrap();
1161        std::fs::write(dir.join("models.json"), "not json").unwrap();
1162        assert!(configure_pi_endpoint(&dir, &super::tests::endpoint(), "sk").is_err());
1163        assert!(!dir.join("auth.json").exists());
1164        assert!(!dir.join("settings.json").exists());
1165    }
1166
1167    #[test]
1168    fn invalid_input_writes_nothing() {
1169        let (source, destination) = homes();
1170        std::fs::write(source.path().join("config.toml"), "model = \"m\"\n").unwrap();
1171        std::fs::write(source.path().join("auth.json"), "not json").unwrap();
1172        assert!(import_codex(source.path(), destination.path()).is_err());
1173        std::fs::write(source.path().join("config.toml"), "model = ").unwrap();
1174        std::fs::remove_file(source.path().join("auth.json")).unwrap();
1175        assert!(import_codex(source.path(), destination.path()).is_err());
1176        assert_eq!(std::fs::read_dir(destination.path()).unwrap().count(), 0);
1177
1178        let empty = tempfile::tempdir().unwrap();
1179        assert!(import_codex(empty.path(), destination.path()).is_err());
1180        assert!(import_codex(destination.path(), destination.path()).is_err());
1181    }
1182}