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