Skip to main content

scv_tools/delegate/
stores.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 _;
6use std::path::Path;
7
8use anyhow::{Context, Result, anyhow, bail};
9
10use super::adapters::KeyStore;
11
12const MAX_IMPORT_BYTES: u64 = 1024 * 1024;
13
14enum Auth {
15    /// An API key, which is static and safe to hold in two homes.
16    ApiKey,
17    /// A ChatGPT sign-in, whose rotating refresh token must stay in one home.
18    Session,
19    None,
20}
21
22/// Copy `config.toml` and an API-key `auth.json` from a Codex home into
23/// `destination`, returning display lines that never contain secret values.
24/// Both files are validated before either is written.
25pub fn import_codex(source: &Path, destination: &Path) -> Result<Vec<String>> {
26    let source = std::fs::canonicalize(source)
27        .with_context(|| format!("resolve Codex home {}", source.display()))?;
28    let destination = std::fs::canonicalize(destination)
29        .with_context(|| format!("resolve SCV Codex home {}", destination.display()))?;
30    if source == destination {
31        bail!(
32            "{} is already SCV's Codex agent home; pass your own Codex home with --from",
33            source.display()
34        );
35    }
36    let config = read_bounded(&source.join("config.toml"))?;
37    let auth = read_bounded(&source.join("auth.json"))?;
38    if config.is_none() && auth.is_none() {
39        bail!("no config.toml or auth.json in {}", source.display());
40    }
41    let table = config
42        .as_deref()
43        .map(str::parse::<toml::Table>)
44        .transpose()
45        .context("parse Codex config.toml")?;
46    let auth_kind = auth.as_deref().map(classify_auth).transpose()?;
47
48    let mut notes = Vec::new();
49    if let (Some(text), Some(table)) = (&config, &table) {
50        write_private(&destination.join("config.toml"), text)?;
51        notes.push(format!("Copied config.toml{}", describe(table)));
52        notes.extend(config_notes(table));
53    }
54    match (&auth, auth_kind) {
55        (Some(text), Some(Auth::ApiKey)) => {
56            write_private(&destination.join("auth.json"), text)?;
57            notes.push("Copied the API-key sign-in from auth.json".into());
58        }
59        (Some(_), Some(Auth::Session)) => notes.push(
60            "Skipped auth.json: a ChatGPT sign-in's refresh token must not be shared; \
61             sign SCV in separately with `scv agents login codex`"
62                .into(),
63        ),
64        (Some(_), Some(Auth::None)) => notes.push("Skipped auth.json: it holds no API key".into()),
65        _ => {}
66    }
67    Ok(notes)
68}
69
70/// The files [`import_codex`] copies from `source`: `config.toml` when
71/// present, and `auth.json` when it holds an API key.
72pub fn codex_copied_files(source: &Path) -> Vec<String> {
73    let mut files = Vec::new();
74    if source.join("config.toml").is_file() {
75        files.push("config.toml".to_owned());
76    }
77    if let Ok(Some(text)) = read_bounded(&source.join("auth.json"))
78        && matches!(classify_auth(&text), Ok(Auth::ApiKey))
79    {
80        files.push("auth.json".to_owned());
81    }
82    files
83}
84
85fn read_bounded(path: &Path) -> Result<Option<String>> {
86    let file = match std::fs::File::open(path) {
87        Ok(file) => file,
88        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
89        Err(error) => return Err(anyhow!(error).context(format!("open {}", path.display()))),
90    };
91    if !file.metadata()?.is_file() {
92        bail!("{} is not a regular file", path.display());
93    }
94    let mut text = String::new();
95    file.take(MAX_IMPORT_BYTES + 1)
96        .read_to_string(&mut text)
97        .with_context(|| format!("read {}", path.display()))?;
98    if text.len() as u64 > MAX_IMPORT_BYTES {
99        bail!("{} exceeds 1 MiB", path.display());
100    }
101    Ok(Some(text))
102}
103
104fn classify_auth(text: &str) -> Result<Auth> {
105    let value: serde_json::Value =
106        serde_json::from_str(text).map_err(|_| anyhow!("Codex auth.json is not valid JSON"))?;
107    let object = value
108        .as_object()
109        .ok_or_else(|| anyhow!("Codex auth.json is not a JSON object"))?;
110    if object.get("tokens").is_some_and(|tokens| !tokens.is_null()) {
111        return Ok(Auth::Session);
112    }
113    let has_key = object
114        .get("OPENAI_API_KEY")
115        .and_then(serde_json::Value::as_str)
116        .is_some_and(|key| !key.trim().is_empty());
117    Ok(if has_key { Auth::ApiKey } else { Auth::None })
118}
119
120/// Non-secret identifying settings, debug-quoted so they are terminal-safe.
121fn describe(table: &toml::Table) -> String {
122    let fields: Vec<String> = ["model_provider", "model"]
123        .into_iter()
124        .filter_map(|key| Some(format!("{key} {:?}", table.get(key)?.as_str()?)))
125        .collect();
126    if fields.is_empty() {
127        String::new()
128    } else {
129        format!(" ({})", fields.join(", "))
130    }
131}
132
133fn config_notes(table: &toml::Table) -> Vec<String> {
134    let mut notes = Vec::new();
135    let policy: Vec<String> = ["sandbox_mode", "approval_policy"]
136        .into_iter()
137        .filter_map(|key| Some(format!("{key} {:?}", table.get(key)?.as_str()?)))
138        .collect();
139    if !policy.is_empty() {
140        notes.push(format!(
141            "Delegated Codex runs also use {}",
142            policy.join(" and ")
143        ));
144    }
145    let providers = table
146        .get("model_providers")
147        .and_then(toml::Value::as_table)
148        .into_iter()
149        .flatten();
150    for (name, provider) in providers {
151        let Some(variable) = provider.get("env_key").and_then(toml::Value::as_str) else {
152            continue;
153        };
154        notes.push(
155            if super::adapters::is_removed_agent_variable(std::ffi::OsStr::new(variable)) {
156                format!(
157                    "Warning: provider {name:?} reads its key from ${variable}, which SCV \
158                     removes from delegated agents; keep the key in auth.json \
159                     (requires_openai_auth) or experimental_bearer_token instead"
160                )
161            } else {
162                format!(
163                    "Note: provider {name:?} reads its key from ${variable}; the SCV daemon's \
164                     environment must provide it (the user service does not load your shell profile)"
165                )
166            },
167        );
168    }
169    notes
170}
171
172/// Copy the user's Grok `config.toml` from `source` (a Grok home) into
173/// `destination` (SCV's Grok home), returning display lines that never
174/// contain secret values. Top-level tables from the user's file win; tables
175/// only SCV's copy has, such as the `[marketplace]` state Grok writes there,
176/// are kept. `auth.json` sign-ins are never copied. The merged file is
177/// validated before anything is written.
178pub fn import_grok(source: &Path, destination: &Path) -> Result<Vec<String>> {
179    let source = std::fs::canonicalize(source)
180        .with_context(|| format!("resolve Grok home {}", source.display()))?;
181    std::fs::create_dir_all(destination)
182        .with_context(|| format!("create {}", destination.display()))?;
183    let destination = std::fs::canonicalize(destination)
184        .with_context(|| format!("resolve SCV Grok home {}", destination.display()))?;
185    if source == destination {
186        bail!(
187            "{} is already SCV's Grok home; pass your own Grok home with --from",
188            source.display()
189        );
190    }
191    let text = read_bounded(&source.join("config.toml"))?
192        .ok_or_else(|| anyhow!("no config.toml in {}", source.display()))?;
193    // Never echo parse errors' source text: these files hold keys.
194    let user: toml::Table = text
195        .parse()
196        .map_err(|_| anyhow!("your Grok config.toml is not valid TOML"))?;
197    let target = destination.join("config.toml");
198    let existing: toml::Table = match read_bounded(&target)? {
199        Some(existing) => existing.parse().map_err(|_| {
200            anyhow!(
201                "SCV's Grok config.toml ({}) is not valid TOML; move it aside and import again",
202                target.display()
203            )
204        })?,
205        None => toml::Table::new(),
206    };
207    let kept: toml::Table = existing
208        .into_iter()
209        .filter(|(key, _)| !user.contains_key(key))
210        .collect();
211    let merged = if kept.values().all(toml::Value::is_table) {
212        // Appending whole tables keeps the user's own formatting and comments.
213        let mut merged = text.trim_end().to_owned();
214        merged.push('\n');
215        if !kept.is_empty() {
216            merged.push('\n');
217            merged.push_str(&toml::to_string(&kept).context("serialize kept settings")?);
218        }
219        merged
220    } else {
221        // A kept top-level value would land inside the user's last table if
222        // appended, so write the merged table instead.
223        let mut table = user.clone();
224        table.extend(kept.clone());
225        toml::to_string(&table).context("serialize merged Grok config")?
226    };
227    if merged.parse::<toml::Table>().is_err() {
228        bail!("the merged Grok config.toml would not be valid TOML; nothing was written");
229    }
230    write_private(&target, &merged)?;
231
232    let mut notes = vec![format!("Copied config.toml{}", describe_grok(&user))];
233    if !kept.is_empty() {
234        let names: Vec<String> = kept.keys().map(|key| format!("{key:?}")).collect();
235        notes.push(format!("Kept SCV-only settings: {}", names.join(", ")));
236    }
237    match grok_default_key(&user) {
238        GrokKey::InConfig(_) | GrokKey::NoDefault => {}
239        GrokKey::FromVariable(model, variable) => notes.push(grok_variable_note(&model, &variable)),
240        GrokKey::Missing(model) => notes.push(format!(
241            "Note: default model {model:?} has no api_key in config; sign SCV in with \
242             `scv agents login grok` or add api_key to its profile"
243        )),
244    }
245    if source.join("auth.json").exists() {
246        notes.push(
247            "Skipped auth.json: `grok login` sign-ins are not shared; sign SCV in \
248             separately with `scv agents login grok` if you need one"
249                .into(),
250        );
251    }
252    Ok(notes)
253}
254
255/// Profiles and the default model, debug-quoted so they are terminal-safe.
256fn describe_grok(table: &toml::Table) -> String {
257    let profiles: Vec<String> = table
258        .get("model")
259        .and_then(toml::Value::as_table)
260        .map(|models| models.keys().map(|key| format!("{key:?}")).collect())
261        .unwrap_or_default();
262    let default =
263        grok_default(table).map_or_else(|| "built-in".into(), |model| format!("{model:?}"));
264    if profiles.is_empty() {
265        format!(" (default model {default}; no model profiles)")
266    } else {
267        format!(
268            " (default model {default}; profiles {})",
269            profiles.join(", ")
270        )
271    }
272}
273
274/// Where the key for Grok's default model comes from.
275enum GrokKey {
276    /// No `[models] default`: Grok's built-in default needs `grok login`.
277    NoDefault,
278    /// The default's profile holds an `api_key`.
279    InConfig(String),
280    /// The default's profile reads its key from these variables.
281    FromVariable(String, Vec<String>),
282    /// The default has no profile key.
283    Missing(String),
284}
285
286fn grok_default(table: &toml::Table) -> Option<String> {
287    table
288        .get("models")?
289        .get("default")?
290        .as_str()
291        .map(ToOwned::to_owned)
292}
293
294/// Resolve the default model's profile, by catalog key or by model id as
295/// Grok does, and say where its key comes from.
296fn grok_default_key(table: &toml::Table) -> GrokKey {
297    let Some(default) = grok_default(table) else {
298        return GrokKey::NoDefault;
299    };
300    let models = table.get("model").and_then(toml::Value::as_table);
301    let profile = models.and_then(|models| {
302        models.get(&default).or_else(|| {
303            models.values().find(|profile| {
304                profile.get("model").and_then(toml::Value::as_str) == Some(&default)
305            })
306        })
307    });
308    let Some(profile) = profile else {
309        return GrokKey::Missing(default);
310    };
311    if profile
312        .get("api_key")
313        .and_then(toml::Value::as_str)
314        .is_some_and(|key| !key.trim().is_empty())
315    {
316        return GrokKey::InConfig(default);
317    }
318    let variables: Vec<String> = match profile.get("env_key") {
319        Some(toml::Value::String(name)) => vec![name.clone()],
320        Some(toml::Value::Array(names)) => names
321            .iter()
322            .filter_map(toml::Value::as_str)
323            .map(ToOwned::to_owned)
324            .collect(),
325        _ => Vec::new(),
326    };
327    if variables.is_empty() {
328        GrokKey::Missing(default)
329    } else {
330        GrokKey::FromVariable(default, variables)
331    }
332}
333
334/// A variable Grok can read in a delegated run: set here and not one SCV
335/// removes from delegated agents.
336fn usable_grok_variable(variables: &[String]) -> Option<&String> {
337    variables.iter().find(|variable| {
338        !super::adapters::is_removed_agent_variable(std::ffi::OsStr::new(variable.as_str()))
339            && std::env::var_os(variable).is_some_and(|value| !value.is_empty())
340    })
341}
342
343fn grok_variable_note(model: &str, variables: &[String]) -> String {
344    let names: Vec<String> = variables.iter().map(|name| format!("${name}")).collect();
345    format!(
346        "Note: default model {model:?} reads its key from {}; SCV removes key variables \
347         from delegated agents and its service does not load your shell profile, so put \
348         api_key in the profile instead",
349        names.join(" or ")
350    )
351}
352
353/// Whether an agent's stored sign-in is usable, with display lines that
354/// never contain a secret.
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct StoredStatus {
357    pub ready: bool,
358    pub lines: Vec<String>,
359}
360
361impl StoredStatus {
362    fn ready(line: String) -> Self {
363        Self {
364            ready: true,
365            lines: vec![line],
366        }
367    }
368
369    fn missing(lines: Vec<String>) -> Self {
370        Self {
371            ready: false,
372            lines,
373        }
374    }
375}
376
377fn grok_status(auth: &Path, config: &Path, home: &Path) -> Result<StoredStatus> {
378    let entries = read_json_object(auth)?.map_or(0, |object| {
379        object.values().filter(|value| !value.is_null()).count()
380    });
381    if entries > 0 {
382        return Ok(StoredStatus::ready(format!(
383            "signed in ({})",
384            display(auth, home)
385        )));
386    }
387    let Some(text) = read_bounded(config)? else {
388        return Ok(StoredStatus::missing(vec!["not signed in".into()]));
389    };
390    let table: toml::Table = text
391        .parse()
392        .map_err(|_| anyhow!("{} is not valid TOML", display(config, home)))?;
393    Ok(match grok_default_key(&table) {
394        GrokKey::InConfig(model) => {
395            StoredStatus::ready(format!("signed in (API key in config, model {model:?})"))
396        }
397        GrokKey::FromVariable(model, variables) => match usable_grok_variable(&variables) {
398            Some(variable) => {
399                StoredStatus::ready(format!("signed in (key from ${variable}, model {model:?})"))
400            }
401            None => StoredStatus::missing(vec![
402                "not signed in".into(),
403                grok_variable_note(&model, &variables),
404            ]),
405        },
406        GrokKey::Missing(model) => StoredStatus::missing(vec![
407            "not signed in".into(),
408            format!("default model {model:?} has no api_key in config"),
409        ]),
410        GrokKey::NoDefault => StoredStatus::missing(vec!["not signed in".into()]),
411    })
412}
413
414fn write_private(path: &Path, contents: &str) -> Result<()> {
415    scv_client::fs::replace_private(path, contents.as_bytes())
416        .with_context(|| format!("replace {}", path.display()))
417}
418
419/// Longest API key or endpoint field SCV accepts.
420pub const MAX_FIELD_BYTES: usize = 4096;
421
422/// The pi provider id SCV writes for an OpenAI-compatible endpoint.
423pub(crate) const PI_PROVIDER: &str = "scv";
424
425/// Which OpenAI wire protocol an endpoint speaks.
426#[derive(Debug, Clone, Copy, PartialEq, Eq)]
427pub enum WireApi {
428    Responses,
429    ChatCompletions,
430}
431
432impl WireApi {
433    fn pi_api(self) -> &'static str {
434        match self {
435            Self::Responses => "openai-responses",
436            Self::ChatCompletions => "openai-completions",
437        }
438    }
439}
440
441/// An OpenAI-compatible endpoint for pi, without its key.
442#[derive(Debug, Clone)]
443pub struct Endpoint {
444    pub base_url: String,
445    pub api: WireApi,
446    pub model: String,
447}
448
449/// Check a key or secret SCV is about to store: one line of at most
450/// [`MAX_FIELD_BYTES`] printable characters, without quotes or backslashes.
451pub fn validate_secret(secret: &str) -> Result<()> {
452    if secret.is_empty() {
453        bail!("no key entered");
454    }
455    if secret.len() > MAX_FIELD_BYTES
456        || secret
457            .chars()
458            .any(|c| c.is_whitespace() || c.is_control() || c == '"' || c == '\\')
459    {
460        bail!("the key must be one line of at most {MAX_FIELD_BYTES} printable characters");
461    }
462    Ok(())
463}
464
465/// Store an API key in `store`, inside the adapter `home`.
466pub fn store_key(store: KeyStore, home: &Path, key: &str) -> Result<Vec<String>> {
467    validate_secret(key)?;
468    match store {
469        KeyStore::DshRefs { path, variable } => {
470            let path = home.join(path);
471            create_private_dirs(home, &path)?;
472            // serde_json quoting is a valid YAML double-quoted scalar.
473            let quoted = serde_json::to_string(key)?;
474            write_private(
475                &path,
476                &format!("version: 1\n\nrefs:\n  {variable}: {quoted}\n"),
477            )?;
478            Ok(vec![format!(
479                "Stored the API key as {variable} in {}",
480                display(&path, home)
481            )])
482        }
483        KeyStore::Grok { .. } | KeyStore::Pi { .. } => {
484            bail!("this agent signs in with its own login, not a stored key")
485        }
486        KeyStore::Scv { .. } => {
487            bail!("the nested SCV uses SCV's own provider: run `scv agents import scv`")
488        }
489    }
490}
491
492/// Describe what `store` holds, never printing a secret.
493pub fn stored_status(store: KeyStore, home: &Path) -> Result<StoredStatus> {
494    match store {
495        KeyStore::Grok { auth, config } => grok_status(&home.join(auth), &home.join(config), home),
496        KeyStore::DshRefs { path, variable } => {
497            let path = home.join(path);
498            let stored = read_bounded(&path)?.is_some_and(|text| {
499                text.lines().any(|line| {
500                    line.trim_start()
501                        .strip_prefix(variable)
502                        .and_then(|rest| rest.strip_prefix(':'))
503                        .is_some_and(|value| !matches!(value.trim(), "" | "\"\"" | "''"))
504                })
505            });
506            Ok(if stored {
507                StoredStatus::ready(format!("API key stored as {variable}"))
508            } else {
509                StoredStatus::missing(vec!["not signed in".into()])
510            })
511        }
512        KeyStore::Pi { dir } => pi_status(&home.join(dir)),
513        KeyStore::Scv { config } => scv_child_status(&home.join(config)),
514    }
515}
516
517/// Remove the credentials SCV can see in `store`.
518pub fn remove_stored(store: KeyStore, home: &Path) -> Result<Vec<String>> {
519    match store {
520        KeyStore::Grok { auth: path, .. }
521        | KeyStore::DshRefs { path, .. }
522        | KeyStore::Scv { config: path } => {
523            let path = home.join(path);
524            Ok(vec![if remove_if_present(&path)? {
525                format!("Removed {}", display(&path, home))
526            } else {
527                "Nothing stored".into()
528            }])
529        }
530        KeyStore::Pi { dir } => {
531            let dir = home.join(dir);
532            let mut notes = Vec::new();
533            if remove_if_present(&dir.join("auth.json"))? {
534                notes.push("Removed pi's stored sign-ins (auth.json)".into());
535            }
536            let models = dir.join("models.json");
537            if let Some(mut object) = read_json_object(&models)?
538                && let Some(providers) = object
539                    .get_mut("providers")
540                    .and_then(serde_json::Value::as_object_mut)
541                && providers.remove(PI_PROVIDER).is_some()
542            {
543                write_json(&models, &object)?;
544                notes.push(format!(
545                    "Removed the {PI_PROVIDER} endpoint from models.json"
546                ));
547            }
548            let settings = dir.join("settings.json");
549            if let Some(mut object) = read_json_object(&settings)?
550                && object
551                    .get("defaultProvider")
552                    .and_then(serde_json::Value::as_str)
553                    == Some(PI_PROVIDER)
554            {
555                object.remove("defaultProvider");
556                object.remove("defaultModel");
557                write_json(&settings, &object)?;
558                notes.push("Cleared pi's default model".into());
559            }
560            if notes.is_empty() {
561                notes.push("Nothing stored".into());
562            }
563            Ok(notes)
564        }
565    }
566}
567
568/// The provider profile name `scv agents import scv` writes for the nested SCV.
569pub(crate) const SCV_CHILD_PROVIDER: &str = "scv";
570
571/// The parts of SCV's own provider that the nested SCV copies.
572pub struct ScvChildProvider<'a> {
573    pub wire_api: &'a str,
574    pub model: &'a str,
575    pub base_url: &'a str,
576    pub timeout_seconds: u64,
577    pub headers: &'a std::collections::HashMap<String, scv_client::Secret>,
578    /// Offer the endpoint's hosted web search, as SCV's own config does.
579    pub hosted_web_search: bool,
580}
581
582/// Write the nested SCV's `config.toml` in `home` (mode 0600): SCV's own
583/// provider as profile `scv` (`SCV_CHILD_PROVIDER`), with `key` stored in
584/// the file because delegated agents never inherit key variables. Other
585/// settings already in the file are kept. Returns display lines without the
586/// key.
587pub fn configure_scv_child(
588    home: &Path,
589    provider: &ScvChildProvider<'_>,
590    key: &str,
591) -> Result<Vec<String>> {
592    validate_secret(key)?;
593    let base_url = validate_base_url(provider.base_url)?;
594    if !valid_model_id(provider.model) {
595        bail!("invalid model id {:?}", provider.model);
596    }
597    let path = home.join("config.toml");
598    create_private_dirs(home, &path)?;
599    let mut table: toml::Table = match read_bounded(&path)? {
600        Some(text) => text
601            .parse()
602            .with_context(|| format!("parse existing {}", display(&path, home)))?,
603        None => toml::Table::new(),
604    };
605    let mut selection = toml::Table::new();
606    selection.insert("active".into(), SCV_CHILD_PROVIDER.into());
607    table.insert("provider".into(), selection.into());
608    let mut profile = toml::Table::new();
609    profile.insert("kind".into(), "openai-compatible".into());
610    profile.insert("wire_api".into(), provider.wire_api.into());
611    profile.insert("model".into(), provider.model.into());
612    profile.insert("base_url".into(), base_url.clone().into());
613    profile.insert("api_key".into(), key.into());
614    profile.insert(
615        "timeout_seconds".into(),
616        i64::try_from(provider.timeout_seconds)
617            .unwrap_or(i64::MAX)
618            .into(),
619    );
620    if !provider.headers.is_empty() {
621        let headers: toml::Table = provider
622            .headers
623            .iter()
624            .map(|(name, value)| (name.clone(), value.expose().into()))
625            .collect();
626        profile.insert("headers".into(), headers.into());
627    }
628    let providers = table
629        .entry("providers")
630        .or_insert_with(|| toml::Table::new().into());
631    let Some(providers) = providers.as_table_mut() else {
632        bail!(
633            "{} has a `providers` value that is not a table",
634            display(&path, home)
635        );
636    };
637    providers.insert(SCV_CHILD_PROVIDER.into(), profile.into());
638    if provider.hosted_web_search {
639        let web = table
640            .entry("web")
641            .or_insert_with(|| toml::Table::new().into());
642        let Some(web) = web.as_table_mut() else {
643            bail!(
644                "{} has a `web` value that is not a table",
645                display(&path, home)
646            );
647        };
648        web.insert("search".into(), "provider".into());
649    }
650    let text = toml::to_string(&table).context("encode the nested SCV config")?;
651    text.parse::<toml::Table>()
652        .context("the nested SCV config did not round-trip")?;
653    write_private(&path, &text)?;
654    let mut lines = vec![
655        format!(
656            "Wrote {} (mode 0600): provider {SCV_CHILD_PROVIDER:?}, model {:?} at {}",
657            display(&path, home),
658            provider.model,
659            host(&base_url)
660        ),
661        "The API key is stored in that file and not shown".into(),
662    ];
663    if provider.hosted_web_search {
664        lines.push("Hosted web search is on, as in SCV's own config".into());
665    }
666    Ok(lines)
667}
668
669/// What the nested SCV's `config.toml` provides, never printing its key.
670fn scv_child_status(path: &Path) -> Result<StoredStatus> {
671    let Some(text) = read_bounded(path)? else {
672        return Ok(StoredStatus::missing(vec![
673            "not configured: run `scv agents import scv`".into(),
674        ]));
675    };
676    let Ok(table) = text.parse::<toml::Table>() else {
677        return Ok(StoredStatus::missing(vec![
678            "config.toml is not valid TOML".into(),
679        ]));
680    };
681    let active = table
682        .get("provider")
683        .and_then(|provider| provider.get("active"))
684        .and_then(toml::Value::as_str);
685    let profile = active.and_then(|active| {
686        table
687            .get("providers")
688            .and_then(|providers| providers.get(active))
689            .and_then(toml::Value::as_table)
690    });
691    let (Some(active), Some(profile)) = (active, profile) else {
692        return Ok(StoredStatus::missing(vec![
693            "no active provider: run `scv agents import scv`".into(),
694        ]));
695    };
696    let text = |key: &str| profile.get(key).and_then(toml::Value::as_str);
697    let keyed = text("api_key").is_some_and(|key| !key.trim().is_empty());
698    let location = format!(
699        "provider {active:?}: model {:?} at {}",
700        text("model").unwrap_or("unset"),
701        text("base_url").map_or_else(|| "no base URL".into(), host)
702    );
703    Ok(if keyed {
704        StoredStatus::ready(format!("{location}, API key stored"))
705    } else {
706        StoredStatus::missing(vec![format!(
707            "{location}, no stored API key (a key variable is not inherited): run `scv agents import scv`"
708        )])
709    })
710}
711
712/// Point pi at an OpenAI-compatible endpoint as provider `scv`
713/// (`PI_PROVIDER`) and make it pi's default, storing the key in pi's
714/// `auth.json`.
715pub fn configure_pi_endpoint(dir: &Path, endpoint: &Endpoint, key: &str) -> Result<Vec<String>> {
716    validate_secret(key)?;
717    let base_url = validate_base_url(&endpoint.base_url)?;
718    if !valid_model_id(&endpoint.model) {
719        bail!("invalid model id {:?}", endpoint.model);
720    }
721    create_private_dirs(dir, &dir.join("auth.json"))?;
722    // Validate every existing file before changing any of them.
723    let mut models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
724    let mut auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
725    let mut settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
726
727    let providers = models
728        .entry("providers")
729        .or_insert_with(|| serde_json::json!({}));
730    let providers = providers
731        .as_object_mut()
732        .ok_or_else(|| anyhow!("pi models.json has a non-object \"providers\""))?;
733    let mut provider = serde_json::json!({
734        "baseUrl": base_url,
735        "api": endpoint.api.pi_api(),
736        "models": [{"id": endpoint.model}],
737    });
738    if endpoint.api == WireApi::Responses {
739        // pi's default OpenAI affinity header is `session_id`; proxies that
740        // reject underscores in header names answer it with HTTP 520
741        // (verified against a relay). `x-client-request-id` still goes out.
742        provider["compat"] = serde_json::json!({"sessionAffinityFormat": "openai-nosession"});
743    }
744    providers.insert(PI_PROVIDER.into(), provider);
745    auth.insert(
746        PI_PROVIDER.into(),
747        serde_json::json!({"type": "api_key", "key": key}),
748    );
749    settings.insert("defaultProvider".into(), PI_PROVIDER.into());
750    settings.insert("defaultModel".into(), endpoint.model.clone().into());
751
752    write_json(&dir.join("models.json"), &models)?;
753    write_json(&dir.join("auth.json"), &auth)?;
754    write_json(&dir.join("settings.json"), &settings)?;
755    Ok(vec![
756        format!(
757            "Configured pi provider {PI_PROVIDER:?}: {} at {}, model {:?}",
758            endpoint.api.pi_api(),
759            host(&base_url),
760            endpoint.model
761        ),
762        "Stored its API key in pi's auth.json (mode 0600) and made it pi's default".into(),
763    ])
764}
765
766fn pi_status(dir: &Path) -> Result<StoredStatus> {
767    let settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
768    let auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
769    let models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
770    let mut lines = Vec::new();
771    let text = |object: &serde_json::Map<String, serde_json::Value>, key: &str| {
772        object
773            .get(key)
774            .and_then(serde_json::Value::as_str)
775            .map(ToOwned::to_owned)
776    };
777    let provider = text(&settings, "defaultProvider");
778    if let Some(provider) = &provider {
779        let endpoint = models
780            .get("providers")
781            .and_then(|providers| providers.get(provider))
782            .and_then(serde_json::Value::as_object);
783        let location = endpoint
784            .map(|endpoint| {
785                format!(
786                    " ({} at {})",
787                    text(endpoint, "api").unwrap_or_else(|| "api unset".into()),
788                    text(endpoint, "baseUrl")
789                        .map_or_else(|| "no base URL".into(), |url| host(&url))
790                )
791            })
792            .unwrap_or_default();
793        lines.push(format!(
794            "default provider {provider:?}{location}, model {:?}",
795            text(&settings, "defaultModel").unwrap_or_else(|| "unset".into())
796        ));
797    }
798    let mut signed_in: Vec<&String> = auth.keys().collect();
799    signed_in.sort();
800    let ready = !signed_in.is_empty();
801    if ready {
802        let names: Vec<String> = signed_in.iter().map(|name| format!("{name:?}")).collect();
803        lines.push(format!("stored sign-ins: {}", names.join(", ")));
804    } else {
805        lines.push("not signed in".into());
806    }
807    Ok(StoredStatus { ready, lines })
808}
809
810fn validate_base_url(value: &str) -> Result<String> {
811    let value = value.trim().trim_end_matches('/');
812    let rest = value
813        .strip_prefix("https://")
814        .or_else(|| value.strip_prefix("http://"))
815        .ok_or_else(|| anyhow!("the base URL must start with https:// or http://"))?;
816    let authority = rest.split('/').next().unwrap_or_default();
817    if authority.is_empty()
818        || authority.contains('@')
819        || value.len() > MAX_FIELD_BYTES
820        || value
821            .chars()
822            .any(|c| c.is_whitespace() || c.is_control() || matches!(c, '?' | '#'))
823    {
824        bail!("the base URL must be a plain http(s) URL without credentials, query, or fragment");
825    }
826    Ok(value.to_owned())
827}
828
829/// The host of a validated URL, for display.
830fn host(url: &str) -> String {
831    url.split("://")
832        .nth(1)
833        .and_then(|rest| rest.split('/').next())
834        .unwrap_or(url)
835        .to_owned()
836}
837
838fn valid_model_id(model: &str) -> bool {
839    !model.is_empty()
840        && model.len() <= 128
841        && !model.starts_with(['-', '@'])
842        && model
843            .chars()
844            .all(|c| c.is_ascii_alphanumeric() || "._:/@[]-".contains(c))
845}
846
847fn display(path: &Path, home: &Path) -> String {
848    path.strip_prefix(home).map_or_else(
849        |_| path.display().to_string(),
850        |relative| relative.display().to_string(),
851    )
852}
853
854fn read_json_object(path: &Path) -> Result<Option<serde_json::Map<String, serde_json::Value>>> {
855    let Some(text) = read_bounded(path)? else {
856        return Ok(None);
857    };
858    if text.trim().is_empty() {
859        return Ok(Some(serde_json::Map::new()));
860    }
861    match serde_json::from_str(&text) {
862        Ok(serde_json::Value::Object(object)) => Ok(Some(object)),
863        // Never echo the content: these files hold keys.
864        _ => bail!("{} is not a JSON object", path.display()),
865    }
866}
867
868fn write_json(path: &Path, object: &serde_json::Map<String, serde_json::Value>) -> Result<()> {
869    write_private(
870        path,
871        &format!("{}\n", serde_json::to_string_pretty(object)?),
872    )
873}
874
875fn remove_if_present(path: &Path) -> Result<bool> {
876    match std::fs::remove_file(path) {
877        Ok(()) => Ok(true),
878        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
879        Err(error) => Err(anyhow!(error).context(format!("remove {}", path.display()))),
880    }
881}
882
883/// Create the directories between `root` and `file` with mode 0700.
884fn create_private_dirs(root: &Path, file: &Path) -> Result<()> {
885    let parent = file
886        .parent()
887        .ok_or_else(|| anyhow!("{} has no parent", file.display()))?;
888    std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
889    use std::os::unix::fs::PermissionsExt as _;
890    let mut dir = parent;
891    loop {
892        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
893        match dir.parent() {
894            Some(next) if next.starts_with(root) && next != root => dir = next,
895            _ => break,
896        }
897    }
898    Ok(())
899}
900
901#[cfg(test)]
902mod tests;