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        KeyStore::Scv { .. } => {
516            bail!("the nested SCV uses SCV's own provider: run `scv agents import scv`")
517        }
518    }
519}
520
521/// Describe what `store` holds, never printing a secret.
522pub fn stored_status(store: KeyStore, home: &Path) -> Result<(bool, Vec<String>)> {
523    match store {
524        KeyStore::Grok { auth, config } => grok_status(&home.join(auth), &home.join(config), home),
525        KeyStore::DshRefs { path, variable } => {
526            let path = home.join(path);
527            let stored = read_bounded(&path)?.is_some_and(|text| {
528                text.lines().any(|line| {
529                    line.trim_start()
530                        .strip_prefix(variable)
531                        .and_then(|rest| rest.strip_prefix(':'))
532                        .is_some_and(|value| !matches!(value.trim(), "" | "\"\"" | "''"))
533                })
534            });
535            Ok(if stored {
536                (true, vec![format!("API key stored as {variable}")])
537            } else {
538                (false, vec!["not signed in".into()])
539            })
540        }
541        KeyStore::Pi { dir } => pi_status(&home.join(dir)),
542        KeyStore::Scv { config } => scv_child_status(&home.join(config)),
543    }
544}
545
546/// Remove the credentials SCV can see in `store`.
547pub fn remove_stored(store: KeyStore, home: &Path) -> Result<Vec<String>> {
548    match store {
549        KeyStore::Grok { auth: path, .. }
550        | KeyStore::DshRefs { path, .. }
551        | KeyStore::Scv { config: path } => {
552            let path = home.join(path);
553            Ok(vec![if remove_if_present(&path)? {
554                format!("Removed {}", display(&path, home))
555            } else {
556                "Nothing stored".into()
557            }])
558        }
559        KeyStore::Pi { dir } => {
560            let dir = home.join(dir);
561            let mut notes = Vec::new();
562            if remove_if_present(&dir.join("auth.json"))? {
563                notes.push("Removed pi's stored sign-ins (auth.json)".into());
564            }
565            let models = dir.join("models.json");
566            if let Some(mut object) = read_json_object(&models)?
567                && let Some(providers) = object
568                    .get_mut("providers")
569                    .and_then(serde_json::Value::as_object_mut)
570                && providers.remove(PI_PROVIDER).is_some()
571            {
572                write_json(&models, &object)?;
573                notes.push(format!(
574                    "Removed the {PI_PROVIDER} endpoint from models.json"
575                ));
576            }
577            let settings = dir.join("settings.json");
578            if let Some(mut object) = read_json_object(&settings)?
579                && object
580                    .get("defaultProvider")
581                    .and_then(serde_json::Value::as_str)
582                    == Some(PI_PROVIDER)
583            {
584                object.remove("defaultProvider");
585                object.remove("defaultModel");
586                write_json(&settings, &object)?;
587                notes.push("Cleared pi's default model".into());
588            }
589            if notes.is_empty() {
590                notes.push("Nothing stored".into());
591            }
592            Ok(notes)
593        }
594    }
595}
596
597/// The provider profile name `scv agents import scv` writes for the nested SCV.
598pub const SCV_CHILD_PROVIDER: &str = "scv";
599
600/// The parts of SCV's own provider that the nested SCV copies.
601pub struct ScvChildProvider<'a> {
602    pub wire_api: &'a str,
603    pub model: &'a str,
604    pub base_url: &'a str,
605    pub timeout_seconds: u64,
606    pub headers: &'a std::collections::HashMap<String, String>,
607    /// Offer the endpoint's hosted web search, as SCV's own config does.
608    pub hosted_web_search: bool,
609}
610
611/// Write the nested SCV's `config.toml` in `home` (mode 0600): SCV's own
612/// provider as profile [`SCV_CHILD_PROVIDER`], with `key` stored in the file
613/// because delegated agents never inherit key variables. Other settings
614/// already in the file are kept. Returns display lines without the key.
615pub fn configure_scv_child(
616    home: &Path,
617    provider: &ScvChildProvider<'_>,
618    key: &str,
619) -> Result<Vec<String>> {
620    validate_secret(key)?;
621    let base_url = validate_base_url(provider.base_url)?;
622    if !valid_model_id(provider.model) {
623        bail!("invalid model id {:?}", provider.model);
624    }
625    let path = home.join("config.toml");
626    create_private_dirs(home, &path)?;
627    let mut table: toml::Table = match read_bounded(&path)? {
628        Some(text) => text
629            .parse()
630            .with_context(|| format!("parse existing {}", display(&path, home)))?,
631        None => toml::Table::new(),
632    };
633    let mut selection = toml::Table::new();
634    selection.insert("active".into(), SCV_CHILD_PROVIDER.into());
635    table.insert("provider".into(), selection.into());
636    let mut profile = toml::Table::new();
637    profile.insert("kind".into(), "openai-compatible".into());
638    profile.insert("wire_api".into(), provider.wire_api.into());
639    profile.insert("model".into(), provider.model.into());
640    profile.insert("base_url".into(), base_url.clone().into());
641    profile.insert("api_key".into(), key.into());
642    profile.insert(
643        "timeout_seconds".into(),
644        i64::try_from(provider.timeout_seconds)
645            .unwrap_or(i64::MAX)
646            .into(),
647    );
648    if !provider.headers.is_empty() {
649        let headers: toml::Table = provider
650            .headers
651            .iter()
652            .map(|(name, value)| (name.clone(), value.clone().into()))
653            .collect();
654        profile.insert("headers".into(), headers.into());
655    }
656    let providers = table
657        .entry("providers")
658        .or_insert_with(|| toml::Table::new().into());
659    let Some(providers) = providers.as_table_mut() else {
660        bail!(
661            "{} has a `providers` value that is not a table",
662            display(&path, home)
663        );
664    };
665    providers.insert(SCV_CHILD_PROVIDER.into(), profile.into());
666    if provider.hosted_web_search {
667        let web = table
668            .entry("web")
669            .or_insert_with(|| toml::Table::new().into());
670        let Some(web) = web.as_table_mut() else {
671            bail!(
672                "{} has a `web` value that is not a table",
673                display(&path, home)
674            );
675        };
676        web.insert("search".into(), "provider".into());
677    }
678    let text = toml::to_string(&table).context("encode the nested SCV config")?;
679    text.parse::<toml::Table>()
680        .context("the nested SCV config did not round-trip")?;
681    write_private(&path, &text)?;
682    let mut lines = vec![
683        format!(
684            "Wrote {} (mode 0600): provider {SCV_CHILD_PROVIDER:?}, model {:?} at {}",
685            display(&path, home),
686            provider.model,
687            host(&base_url)
688        ),
689        "The API key is stored in that file and not shown".into(),
690    ];
691    if provider.hosted_web_search {
692        lines.push("Hosted web search is on, as in SCV's own config".into());
693    }
694    Ok(lines)
695}
696
697/// What the nested SCV's `config.toml` provides, never printing its key.
698fn scv_child_status(path: &Path) -> Result<(bool, Vec<String>)> {
699    let Some(text) = read_bounded(path)? else {
700        return Ok((
701            false,
702            vec!["not configured: run `scv agents import scv`".into()],
703        ));
704    };
705    let Ok(table) = text.parse::<toml::Table>() else {
706        return Ok((false, vec!["config.toml is not valid TOML".into()]));
707    };
708    let active = table
709        .get("provider")
710        .and_then(|provider| provider.get("active"))
711        .and_then(toml::Value::as_str);
712    let profile = active.and_then(|active| {
713        table
714            .get("providers")
715            .and_then(|providers| providers.get(active))
716            .and_then(toml::Value::as_table)
717    });
718    let (Some(active), Some(profile)) = (active, profile) else {
719        return Ok((
720            false,
721            vec!["no active provider: run `scv agents import scv`".into()],
722        ));
723    };
724    let text = |key: &str| profile.get(key).and_then(toml::Value::as_str);
725    let keyed = text("api_key").is_some_and(|key| !key.trim().is_empty());
726    let location = format!(
727        "provider {active:?}: model {:?} at {}",
728        text("model").unwrap_or("unset"),
729        text("base_url").map_or_else(|| "no base URL".into(), host)
730    );
731    Ok(if keyed {
732        (true, vec![format!("{location}, API key stored")])
733    } else {
734        (
735            false,
736            vec![format!(
737                "{location}, no stored API key (a key variable is not inherited): run `scv agents import scv`"
738            )],
739        )
740    })
741}
742
743/// Point pi at an OpenAI-compatible endpoint as provider [`PI_PROVIDER`]
744/// and make it pi's default, storing the key in pi's `auth.json`.
745pub fn configure_pi_endpoint(dir: &Path, endpoint: &Endpoint, key: &str) -> Result<Vec<String>> {
746    validate_secret(key)?;
747    let base_url = validate_base_url(&endpoint.base_url)?;
748    if !valid_model_id(&endpoint.model) {
749        bail!("invalid model id {:?}", endpoint.model);
750    }
751    create_private_dirs(dir, &dir.join("auth.json"))?;
752    // Validate every existing file before changing any of them.
753    let mut models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
754    let mut auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
755    let mut settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
756
757    let providers = models
758        .entry("providers")
759        .or_insert_with(|| serde_json::json!({}));
760    let providers = providers
761        .as_object_mut()
762        .ok_or_else(|| anyhow!("pi models.json has a non-object \"providers\""))?;
763    let mut provider = serde_json::json!({
764        "baseUrl": base_url,
765        "api": endpoint.api.pi_api(),
766        "models": [{"id": endpoint.model}],
767    });
768    if endpoint.api == WireApi::Responses {
769        // pi's default OpenAI affinity header is `session_id`; proxies that
770        // reject underscores in header names answer it with HTTP 520
771        // (verified against a relay). `x-client-request-id` still goes out.
772        provider["compat"] = serde_json::json!({"sessionAffinityFormat": "openai-nosession"});
773    }
774    providers.insert(PI_PROVIDER.into(), provider);
775    auth.insert(
776        PI_PROVIDER.into(),
777        serde_json::json!({"type": "api_key", "key": key}),
778    );
779    settings.insert("defaultProvider".into(), PI_PROVIDER.into());
780    settings.insert("defaultModel".into(), endpoint.model.clone().into());
781
782    write_json(&dir.join("models.json"), &models)?;
783    write_json(&dir.join("auth.json"), &auth)?;
784    write_json(&dir.join("settings.json"), &settings)?;
785    Ok(vec![
786        format!(
787            "Configured pi provider {PI_PROVIDER:?}: {} at {}, model {:?}",
788            endpoint.api.pi_api(),
789            host(&base_url),
790            endpoint.model
791        ),
792        "Stored its API key in pi's auth.json (mode 0600) and made it pi's default".into(),
793    ])
794}
795
796fn pi_status(dir: &Path) -> Result<(bool, Vec<String>)> {
797    let settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
798    let auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
799    let models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
800    let mut lines = Vec::new();
801    let text = |object: &serde_json::Map<String, serde_json::Value>, key: &str| {
802        object
803            .get(key)
804            .and_then(serde_json::Value::as_str)
805            .map(ToOwned::to_owned)
806    };
807    let provider = text(&settings, "defaultProvider");
808    if let Some(provider) = &provider {
809        let endpoint = models
810            .get("providers")
811            .and_then(|providers| providers.get(provider))
812            .and_then(serde_json::Value::as_object);
813        let location = endpoint
814            .map(|endpoint| {
815                format!(
816                    " ({} at {})",
817                    text(endpoint, "api").unwrap_or_else(|| "api unset".into()),
818                    text(endpoint, "baseUrl")
819                        .map_or_else(|| "no base URL".into(), |url| host(&url))
820                )
821            })
822            .unwrap_or_default();
823        lines.push(format!(
824            "default provider {provider:?}{location}, model {:?}",
825            text(&settings, "defaultModel").unwrap_or_else(|| "unset".into())
826        ));
827    }
828    let mut signed_in: Vec<&String> = auth.keys().collect();
829    signed_in.sort();
830    let ready = !signed_in.is_empty();
831    if ready {
832        let names: Vec<String> = signed_in.iter().map(|name| format!("{name:?}")).collect();
833        lines.push(format!("stored sign-ins: {}", names.join(", ")));
834    } else {
835        lines.push("not signed in".into());
836    }
837    Ok((ready, lines))
838}
839
840fn validate_base_url(value: &str) -> Result<String> {
841    let value = value.trim().trim_end_matches('/');
842    let rest = value
843        .strip_prefix("https://")
844        .or_else(|| value.strip_prefix("http://"))
845        .ok_or_else(|| anyhow!("the base URL must start with https:// or http://"))?;
846    let authority = rest.split('/').next().unwrap_or_default();
847    if authority.is_empty()
848        || authority.contains('@')
849        || value.len() > MAX_FIELD_BYTES
850        || value
851            .chars()
852            .any(|c| c.is_whitespace() || c.is_control() || matches!(c, '?' | '#'))
853    {
854        bail!("the base URL must be a plain http(s) URL without credentials, query, or fragment");
855    }
856    Ok(value.to_owned())
857}
858
859/// The host of a validated URL, for display.
860fn host(url: &str) -> String {
861    url.split("://")
862        .nth(1)
863        .and_then(|rest| rest.split('/').next())
864        .unwrap_or(url)
865        .to_owned()
866}
867
868fn valid_model_id(model: &str) -> bool {
869    !model.is_empty()
870        && model.len() <= 128
871        && !model.starts_with(['-', '@'])
872        && model
873            .chars()
874            .all(|c| c.is_ascii_alphanumeric() || "._:/@[]-".contains(c))
875}
876
877fn display(path: &Path, home: &Path) -> String {
878    path.strip_prefix(home).map_or_else(
879        |_| path.display().to_string(),
880        |relative| relative.display().to_string(),
881    )
882}
883
884fn read_json_object(path: &Path) -> Result<Option<serde_json::Map<String, serde_json::Value>>> {
885    let Some(text) = read_bounded(path)? else {
886        return Ok(None);
887    };
888    if text.trim().is_empty() {
889        return Ok(Some(serde_json::Map::new()));
890    }
891    match serde_json::from_str(&text) {
892        Ok(serde_json::Value::Object(object)) => Ok(Some(object)),
893        // Never echo the content: these files hold keys.
894        _ => bail!("{} is not a JSON object", path.display()),
895    }
896}
897
898fn write_json(path: &Path, object: &serde_json::Map<String, serde_json::Value>) -> Result<()> {
899    write_private(
900        path,
901        &format!("{}\n", serde_json::to_string_pretty(object)?),
902    )
903}
904
905fn remove_if_present(path: &Path) -> Result<bool> {
906    match std::fs::remove_file(path) {
907        Ok(()) => Ok(true),
908        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
909        Err(error) => Err(anyhow!(error).context(format!("remove {}", path.display()))),
910    }
911}
912
913/// Create the directories between `root` and `file` with mode 0700.
914fn create_private_dirs(root: &Path, file: &Path) -> Result<()> {
915    let parent = file
916        .parent()
917        .ok_or_else(|| anyhow!("{} has no parent", file.display()))?;
918    std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
919    use std::os::unix::fs::PermissionsExt as _;
920    let mut dir = parent;
921    loop {
922        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
923        match dir.parent() {
924            Some(next) if next.starts_with(root) && next != root => dir = next,
925            _ => break,
926        }
927    }
928    Ok(())
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934
935    const CONFIG: &str = r#"model_provider = "relay"
936model = "gpt-test"
937sandbox_mode = "danger-full-access"
938approval_policy = "never"
939
940[model_providers.relay]
941base_url = "https://relay.invalid"
942wire_api = "responses"
943requires_openai_auth = true
944experimental_bearer_token = "sk-bearer-secret"
945"#;
946
947    fn homes() -> (tempfile::TempDir, tempfile::TempDir) {
948        (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap())
949    }
950
951    fn mode(path: &Path) -> u32 {
952        use std::os::unix::fs::PermissionsExt;
953        std::fs::metadata(path).unwrap().permissions().mode() & 0o777
954    }
955
956    #[test]
957    fn copies_config_and_api_key_privately_without_printing_secrets() {
958        let (source, destination) = homes();
959        std::fs::write(source.path().join("config.toml"), CONFIG).unwrap();
960        let auth = r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-auth-secret"}"#;
961        std::fs::write(source.path().join("auth.json"), auth).unwrap();
962        let notes = import_codex(source.path(), destination.path()).unwrap();
963        for file in ["config.toml", "auth.json"] {
964            let copied = destination.path().join(file);
965            assert_eq!(
966                std::fs::read_to_string(&copied).unwrap(),
967                std::fs::read_to_string(source.path().join(file)).unwrap()
968            );
969            assert_eq!(mode(&copied), 0o600);
970        }
971        let notes = notes.join("\n");
972        assert!(notes.contains(r#"model_provider "relay", model "gpt-test""#));
973        assert!(notes.contains(r#"sandbox_mode "danger-full-access" and approval_policy "never""#));
974        assert!(notes.contains("API-key sign-in"));
975        assert!(!notes.contains("secret"));
976    }
977
978    #[test]
979    fn never_copies_a_chatgpt_session() {
980        let (source, destination) = homes();
981        std::fs::write(source.path().join("config.toml"), "model = \"m\"\n").unwrap();
982        std::fs::write(
983            source.path().join("auth.json"),
984            r#"{"auth_mode":"chatgpt","OPENAI_API_KEY":null,"tokens":{"refresh_token":"rt"}}"#,
985        )
986        .unwrap();
987        let notes = import_codex(source.path(), destination.path()).unwrap();
988        assert!(destination.path().join("config.toml").is_file());
989        assert!(!destination.path().join("auth.json").exists());
990        assert!(notes.join("\n").contains("scv agents login codex"));
991    }
992
993    #[test]
994    fn env_key_providers_are_flagged() {
995        let (source, destination) = homes();
996        std::fs::write(
997            source.path().join("config.toml"),
998            "[model_providers.a]\nenv_key = \"OPENAI_API_KEY\"\n\
999             [model_providers.b]\nenv_key = \"RELAY_KEY\"\n",
1000        )
1001        .unwrap();
1002        let notes = import_codex(source.path(), destination.path())
1003            .unwrap()
1004            .join("\n");
1005        assert!(notes.contains("$OPENAI_API_KEY, which SCV removes"));
1006        assert!(notes.contains("$RELAY_KEY; the SCV daemon's environment must provide it"));
1007    }
1008
1009    const DSH: KeyStore = KeyStore::DshRefs {
1010        path: ".dsh/.credentials.yaml",
1011        variable: "DEEPSEEK_API_KEY",
1012    };
1013    const PI: KeyStore = KeyStore::Pi { dir: ".pi/agent" };
1014    const GROK: KeyStore = KeyStore::Grok {
1015        auth: ".grok/auth.json",
1016        config: ".grok/config.toml",
1017    };
1018
1019    const GROK_CONFIG: &str = r#"[cli]
1020installer = "internal"
1021
1022# The relay profile.
1023[model.relay]
1024model = "grok-4.5"
1025base_url = "https://relay.invalid"
1026api_key = "xai-profile-secret"
1027api_backend = "responses"
1028
1029[model."relay-4.7"]
1030model = "grok-4.7"
1031base_url = "https://relay.invalid"
1032api_key = "xai-profile-secret"
1033
1034[models]
1035default = "relay-4.7"
1036"#;
1037
1038    fn grok_status_of(config: &str) -> (bool, String) {
1039        let home = tempfile::tempdir().unwrap();
1040        std::fs::create_dir(home.path().join(".grok")).unwrap();
1041        std::fs::write(home.path().join(".grok/config.toml"), config).unwrap();
1042        let (ready, lines) = stored_status(GROK, home.path()).unwrap();
1043        (ready, lines.join("\n"))
1044    }
1045
1046    #[test]
1047    fn grok_config_keys_count_as_signed_in_without_printing_them() {
1048        let (ready, lines) = grok_status_of(GROK_CONFIG);
1049        assert!(ready);
1050        assert_eq!(lines, r#"signed in (API key in config, model "relay-4.7")"#);
1051        // The default may name a model id instead of a catalog key.
1052        let (ready, _) = grok_status_of(
1053            &GROK_CONFIG.replace(r#"default = "relay-4.7""#, r#"default = "grok-4.5""#),
1054        );
1055        assert!(ready);
1056        // A default without a key, an unknown default, and no default at all.
1057        let keyless = "[model.m]\nmodel = \"grok-4.7\"\n[models]\ndefault = \"m\"\n";
1058        let (ready, lines) = grok_status_of(keyless);
1059        assert!(!ready);
1060        assert!(lines.contains(r#"default model "m" has no api_key in config"#));
1061        assert!(!grok_status_of("[models]\ndefault = \"grok-9\"\n").0);
1062        assert!(!grok_status_of("[cli]\ninstaller = \"internal\"\n").0);
1063        // A key variable SCV removes from delegated agents does not count.
1064        let removed = "[model.m]\nenv_key = [\"XAI_API_KEY\"]\n[models]\ndefault = \"m\"\n";
1065        let (ready, lines) = grok_status_of(removed);
1066        assert!(!ready);
1067        assert!(lines.contains("$XAI_API_KEY"), "{lines}");
1068        // An unparsable config is an error that never echoes its content.
1069        let home = tempfile::tempdir().unwrap();
1070        std::fs::create_dir(home.path().join(".grok")).unwrap();
1071        std::fs::write(
1072            home.path().join(".grok/config.toml"),
1073            "api_key = xai-secret",
1074        )
1075        .unwrap();
1076        let error = stored_status(GROK, home.path()).unwrap_err().to_string();
1077        assert!(!error.contains("secret"), "{error}");
1078    }
1079
1080    #[test]
1081    fn grok_import_merges_by_table_privately_without_printing_keys() {
1082        let (source, scv) = homes();
1083        let destination = scv.path().join(".grok");
1084        std::fs::create_dir(&destination).unwrap();
1085        std::fs::write(source.path().join("config.toml"), GROK_CONFIG).unwrap();
1086        std::fs::write(
1087            source.path().join("auth.json"),
1088            r#"{"a":{"key":"xai-token-secret"}}"#,
1089        )
1090        .unwrap();
1091        // Grok's own state in SCV's copy survives; a stale user table does not.
1092        std::fs::write(
1093            destination.join("config.toml"),
1094            "[marketplace]\ndefault_skills_installs_purged = true\n\n[cli]\ninstaller = \"old\"\n",
1095        )
1096        .unwrap();
1097        let notes = import_grok(source.path(), &destination).unwrap().join("\n");
1098        let copied = destination.join("config.toml");
1099        assert_eq!(mode(&copied), 0o600);
1100        let text = std::fs::read_to_string(&copied).unwrap();
1101        assert!(text.starts_with(GROK_CONFIG), "user formatting is kept");
1102        let table: toml::Table = text.parse().unwrap();
1103        assert_eq!(table["cli"]["installer"].as_str(), Some("internal"));
1104        assert_eq!(
1105            table["marketplace"]["default_skills_installs_purged"].as_bool(),
1106            Some(true)
1107        );
1108        assert_eq!(table["models"]["default"].as_str(), Some("relay-4.7"));
1109        assert!(!destination.join("auth.json").exists());
1110        assert!(notes.contains(r#"default model "relay-4.7"; profiles "relay", "relay-4.7""#));
1111        assert!(notes.contains(r#"Kept SCV-only settings: "marketplace""#));
1112        assert!(notes.contains("Skipped auth.json"));
1113        assert!(!notes.contains("secret"), "{notes}");
1114        assert!(stored_status(GROK, scv.path()).unwrap().0);
1115    }
1116
1117    #[test]
1118    fn grok_import_writes_nothing_when_either_file_is_invalid() {
1119        let (source, scv) = homes();
1120        let destination = scv.path().join(".grok");
1121        std::fs::create_dir(&destination).unwrap();
1122        let existing = "[marketplace]\nkept = true\n";
1123        std::fs::write(destination.join("config.toml"), existing).unwrap();
1124        std::fs::write(source.path().join("config.toml"), "api_key = xai-secret").unwrap();
1125        let error = import_grok(source.path(), &destination)
1126            .unwrap_err()
1127            .to_string();
1128        assert!(!error.contains("secret"), "{error}");
1129        assert_eq!(
1130            std::fs::read_to_string(destination.join("config.toml")).unwrap(),
1131            existing
1132        );
1133        std::fs::write(source.path().join("config.toml"), GROK_CONFIG).unwrap();
1134        std::fs::write(destination.join("config.toml"), "broken = [").unwrap();
1135        assert!(import_grok(source.path(), &destination).is_err());
1136        assert_eq!(
1137            std::fs::read_to_string(destination.join("config.toml")).unwrap(),
1138            "broken = ["
1139        );
1140        assert!(import_grok(&destination, &destination).is_err());
1141        let empty = tempfile::tempdir().unwrap();
1142        assert!(import_grok(empty.path(), &destination).is_err());
1143    }
1144
1145    #[test]
1146    fn grok_import_keeps_top_level_values_outside_the_users_tables() {
1147        let (source, scv) = homes();
1148        let destination = scv.path().join(".grok");
1149        std::fs::create_dir(&destination).unwrap();
1150        std::fs::write(source.path().join("config.toml"), GROK_CONFIG).unwrap();
1151        std::fs::write(destination.join("config.toml"), "version = 3\n").unwrap();
1152        import_grok(source.path(), &destination).unwrap();
1153        let table: toml::Table = std::fs::read_to_string(destination.join("config.toml"))
1154            .unwrap()
1155            .parse()
1156            .unwrap();
1157        assert_eq!(table["version"].as_integer(), Some(3));
1158        assert!(table["models"].get("version").is_none());
1159        assert_eq!(table["models"]["default"].as_str(), Some("relay-4.7"));
1160    }
1161
1162    fn endpoint() -> Endpoint {
1163        Endpoint {
1164            base_url: "https://relay.invalid/v1/".into(),
1165            api: WireApi::Responses,
1166            model: "gpt-test".into(),
1167        }
1168    }
1169
1170    #[test]
1171    fn dsh_keys_are_stored_privately_in_its_native_file() {
1172        let home = tempfile::tempdir().unwrap();
1173        assert!(!stored_status(DSH, home.path()).unwrap().0);
1174        let notes = store_key(DSH, home.path(), "sk-dsh-secret").unwrap();
1175        assert!(!notes.join("\n").contains("secret"));
1176        let path = home.path().join(".dsh/.credentials.yaml");
1177        assert_eq!(
1178            std::fs::read_to_string(&path).unwrap(),
1179            "version: 1\n\nrefs:\n  DEEPSEEK_API_KEY: \"sk-dsh-secret\"\n"
1180        );
1181        assert_eq!(mode(&path), 0o600);
1182        assert_eq!(mode(&home.path().join(".dsh")), 0o700);
1183        let (ready, lines) = stored_status(DSH, home.path()).unwrap();
1184        assert!(ready);
1185        assert!(!lines.join("\n").contains("secret"));
1186        for invalid in ["", "two words", "quote\"d", "line\nbreak"] {
1187            assert!(store_key(DSH, home.path(), invalid).is_err(), "{invalid:?}");
1188        }
1189        assert_eq!(
1190            remove_stored(DSH, home.path()).unwrap(),
1191            ["Removed .dsh/.credentials.yaml"]
1192        );
1193        assert!(!path.exists());
1194        assert!(!stored_status(DSH, home.path()).unwrap().0);
1195    }
1196
1197    #[test]
1198    fn grok_login_entries_report_sign_in_without_values() {
1199        let home = tempfile::tempdir().unwrap();
1200        let store = GROK;
1201        assert!(!stored_status(store, home.path()).unwrap().0);
1202        std::fs::create_dir(home.path().join(".grok")).unwrap();
1203        std::fs::write(home.path().join(".grok/auth.json"), "{}").unwrap();
1204        assert!(!stored_status(store, home.path()).unwrap().0);
1205        std::fs::write(
1206            home.path().join(".grok/auth.json"),
1207            r#"{"https://auth.x.ai::id":{"key":"xai-token-secret"}}"#,
1208        )
1209        .unwrap();
1210        let (ready, lines) = stored_status(store, home.path()).unwrap();
1211        assert!(ready);
1212        assert!(!lines.join("\n").contains("secret"));
1213        std::fs::write(home.path().join(".grok/auth.json"), "xai-token-secret").unwrap();
1214        let error = stored_status(store, home.path()).unwrap_err().to_string();
1215        assert!(!error.contains("secret"), "{error}");
1216    }
1217
1218    #[test]
1219    fn pi_endpoints_merge_into_pi_files_as_the_private_default() {
1220        let home = tempfile::tempdir().unwrap();
1221        let dir = home.path().join(".pi/agent");
1222        std::fs::create_dir_all(&dir).unwrap();
1223        std::fs::write(
1224            dir.join("models.json"),
1225            r#"{"providers":{"ollama":{"baseUrl":"http://localhost:11434/v1","api":"openai-completions","models":[{"id":"q"}]}}}"#,
1226        )
1227        .unwrap();
1228        std::fs::write(dir.join("settings.json"), r#"{"theme":"dark"}"#).unwrap();
1229        let notes = configure_pi_endpoint(&dir, &endpoint(), "sk-pi-secret").unwrap();
1230        let notes = notes.join("\n");
1231        assert!(
1232            notes.contains("openai-responses at relay.invalid"),
1233            "{notes}"
1234        );
1235        assert!(!notes.contains("secret"));
1236
1237        let read = |file: &str| -> serde_json::Value {
1238            let path = dir.join(file);
1239            assert_eq!(mode(&path), 0o600, "{file}");
1240            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
1241        };
1242        let models = read("models.json");
1243        assert_eq!(
1244            models["providers"]["scv"],
1245            serde_json::json!({
1246                "baseUrl": "https://relay.invalid/v1",
1247                "api": "openai-responses",
1248                "models": [{"id": "gpt-test"}],
1249                "compat": {"sessionAffinityFormat": "openai-nosession"},
1250            })
1251        );
1252        assert_eq!(models["providers"]["ollama"]["models"][0]["id"], "q");
1253        assert_eq!(
1254            read("auth.json")["scv"],
1255            serde_json::json!({"type": "api_key", "key": "sk-pi-secret"})
1256        );
1257        let settings = read("settings.json");
1258        assert_eq!(settings["defaultProvider"], "scv");
1259        assert_eq!(settings["defaultModel"], "gpt-test");
1260        assert_eq!(settings["theme"], "dark");
1261
1262        let (ready, lines) = stored_status(PI, home.path()).unwrap();
1263        let lines = lines.join("\n");
1264        assert!(ready);
1265        assert!(
1266            lines.contains(
1267                r#"default provider "scv" (openai-responses at relay.invalid), model "gpt-test""#
1268            ),
1269            "{lines}"
1270        );
1271        assert!(!lines.contains("secret"));
1272
1273        let removed = remove_stored(PI, home.path()).unwrap().join("\n");
1274        assert!(removed.contains("auth.json"), "{removed}");
1275        assert!(!dir.join("auth.json").exists());
1276        let models = read("models.json");
1277        assert!(models["providers"].get("scv").is_none());
1278        assert!(models["providers"].get("ollama").is_some());
1279        let settings = read("settings.json");
1280        assert!(settings.get("defaultProvider").is_none());
1281        assert_eq!(settings["theme"], "dark");
1282        assert!(!stored_status(PI, home.path()).unwrap().0);
1283    }
1284
1285    #[test]
1286    fn pi_endpoint_input_is_validated_before_any_write() {
1287        let home = tempfile::tempdir().unwrap();
1288        let dir = home.path().join(".pi/agent");
1289        for base_url in [
1290            "ftp://relay.invalid",
1291            "https://user:pass@relay.invalid",
1292            "https://relay.invalid/v1?key=x",
1293            "https://",
1294        ] {
1295            let endpoint = Endpoint {
1296                base_url: base_url.into(),
1297                ..endpoint()
1298            };
1299            assert!(
1300                configure_pi_endpoint(&dir, &endpoint, "sk").is_err(),
1301                "{base_url}"
1302            );
1303        }
1304        let endpoint = Endpoint {
1305            model: "--flag".into(),
1306            ..endpoint()
1307        };
1308        assert!(configure_pi_endpoint(&dir, &endpoint, "sk").is_err());
1309        assert!(configure_pi_endpoint(&dir, &super::tests::endpoint(), "").is_err());
1310        assert!(!dir.exists());
1311
1312        std::fs::create_dir_all(&dir).unwrap();
1313        std::fs::write(dir.join("models.json"), "not json").unwrap();
1314        assert!(configure_pi_endpoint(&dir, &super::tests::endpoint(), "sk").is_err());
1315        assert!(!dir.join("auth.json").exists());
1316        assert!(!dir.join("settings.json").exists());
1317    }
1318
1319    #[test]
1320    fn invalid_input_writes_nothing() {
1321        let (source, destination) = homes();
1322        std::fs::write(source.path().join("config.toml"), "model = \"m\"\n").unwrap();
1323        std::fs::write(source.path().join("auth.json"), "not json").unwrap();
1324        assert!(import_codex(source.path(), destination.path()).is_err());
1325        std::fs::write(source.path().join("config.toml"), "model = ").unwrap();
1326        std::fs::remove_file(source.path().join("auth.json")).unwrap();
1327        assert!(import_codex(source.path(), destination.path()).is_err());
1328        assert_eq!(std::fs::read_dir(destination.path()).unwrap().count(), 0);
1329
1330        let empty = tempfile::tempdir().unwrap();
1331        assert!(import_codex(empty.path(), destination.path()).is_err());
1332        assert!(import_codex(destination.path(), destination.path()).is_err());
1333    }
1334
1335    #[test]
1336    fn the_nested_scv_gets_a_private_copy_of_the_provider() {
1337        let home = tempfile::tempdir().unwrap();
1338        let path = home.path().join("config.toml");
1339        std::fs::write(&path, "[agent]\nmax_steps = 7\n").unwrap();
1340        let headers = std::collections::HashMap::from([("X-Team".to_owned(), "core".to_owned())]);
1341        let key = "sk-nested-secret-0123456789";
1342        let lines = configure_scv_child(
1343            home.path(),
1344            &ScvChildProvider {
1345                wire_api: "responses",
1346                model: "gpt-test",
1347                base_url: "https://relay.invalid/v1/",
1348                timeout_seconds: 600,
1349                headers: &headers,
1350                hosted_web_search: true,
1351            },
1352            key,
1353        )
1354        .unwrap();
1355        assert!(lines.iter().all(|line| !line.contains(key)), "{lines:?}");
1356        assert!(lines[0].contains("gpt-test") && lines[0].contains("relay.invalid"));
1357        assert_eq!(mode(&path), 0o600);
1358        let table: toml::Table = std::fs::read_to_string(&path).unwrap().parse().unwrap();
1359        assert_eq!(
1360            table["agent"]["max_steps"].as_integer(),
1361            Some(7),
1362            "other settings kept"
1363        );
1364        assert_eq!(
1365            table["provider"]["active"].as_str(),
1366            Some(SCV_CHILD_PROVIDER)
1367        );
1368        let profile = &table["providers"][SCV_CHILD_PROVIDER];
1369        assert_eq!(
1370            profile["base_url"].as_str(),
1371            Some("https://relay.invalid/v1")
1372        );
1373        assert_eq!(profile["api_key"].as_str(), Some(key));
1374        assert_eq!(profile["headers"]["X-Team"].as_str(), Some("core"));
1375        assert_eq!(table["web"]["search"].as_str(), Some("provider"));
1376
1377        let store = KeyStore::Scv {
1378            config: "config.toml",
1379        };
1380        let (ready, status) = stored_status(store, home.path()).unwrap();
1381        assert!(ready);
1382        assert!(status.iter().all(|line| !line.contains(key)), "{status:?}");
1383        assert!(status[0].contains("API key stored"), "{status:?}");
1384        assert!(store_key(store, home.path(), key).is_err());
1385        remove_stored(store, home.path()).unwrap();
1386        let (ready, status) = stored_status(store, home.path()).unwrap();
1387        assert!(!ready);
1388        assert!(status[0].contains("scv agents import scv"), "{status:?}");
1389    }
1390
1391    #[test]
1392    fn a_broken_nested_scv_config_is_not_overwritten() {
1393        let home = tempfile::tempdir().unwrap();
1394        let path = home.path().join("config.toml");
1395        std::fs::write(&path, "not = [valid").unwrap();
1396        let headers = std::collections::HashMap::new();
1397        let provider = ScvChildProvider {
1398            wire_api: "responses",
1399            model: "gpt-test",
1400            base_url: "https://relay.invalid",
1401            timeout_seconds: 60,
1402            headers: &headers,
1403            hosted_web_search: false,
1404        };
1405        assert!(configure_scv_child(home.path(), &provider, "sk-key-0123456789").is_err());
1406        assert_eq!(std::fs::read_to_string(&path).unwrap(), "not = [valid");
1407        let bad_url = ScvChildProvider {
1408            base_url: "ftp://relay.invalid",
1409            ..provider
1410        };
1411        std::fs::remove_file(&path).unwrap();
1412        assert!(configure_scv_child(home.path(), &bad_url, "sk-key-0123456789").is_err());
1413        assert!(!path.exists());
1414    }
1415}