Skip to main content

scv_server/
agents.rs

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