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    // A gateway key does not use Anthropic's `sk-ant-` prefix. One trailing
458    // newline is the line ending; the key itself is 1..=512 bytes and
459    // contains no whitespace or control character.
460    let key_value = text.strip_suffix('\n').unwrap_or(text.as_str());
461    let key_value = key_value.strip_suffix('\r').unwrap_or(key_value);
462    if !(1..=512).contains(&key_value.len())
463        || key_value
464            .chars()
465            .any(|character| character.is_whitespace() || character.is_control())
466    {
467        return Ok(StoredStatus::missing(vec![
468            "not signed in (API key file has an invalid shape; run scv agents login claude)".into(),
469        ]));
470    }
471    if let Some(text) = read_bounded(credentials)? {
472        let value: serde_json::Value = match serde_json::from_str(&text) {
473            Ok(value) => value,
474            Err(_) => {
475                return Ok(StoredStatus::missing(vec![
476                    "blocked: Claude credentials file is not valid JSON".into(),
477                ]));
478            }
479        };
480        if value
481            .get("claudeAiOauth")
482            .is_some_and(|oauth| !oauth.is_null())
483        {
484            return Ok(StoredStatus::missing(vec![
485                "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(),
486            ]));
487        }
488    }
489    Ok(StoredStatus::ready("signed in (API key)".into()))
490}
491
492/// Largest `.claude.json` that status will parse. Claude Code owns the file
493/// and it grows with project history. Only a non-empty top-level
494/// `primaryApiKey` matters. A file past this cap blocks the subscription
495/// identity instead of failing status or being read without a bound.
496const MAX_CLAUDE_CONFIG_BYTES: u64 = 8 * 1024 * 1024;
497
498/// Whether `.claude.json` holds a non-empty top-level `primaryApiKey`.
499#[derive(Debug, Clone, Copy, PartialEq, Eq)]
500enum ConsoleKey {
501    Absent,
502    Present,
503    Unparseable,
504    /// Larger than [`MAX_CLAUDE_CONFIG_BYTES`]. Not read.
505    TooLarge,
506}
507
508#[derive(serde::Deserialize)]
509struct ClaudeConfigFile {
510    #[serde(default, rename = "primaryApiKey")]
511    primary_api_key: Option<String>,
512}
513
514fn console_key(path: &Path) -> Result<ConsoleKey> {
515    let file = match std::fs::File::open(path) {
516        Ok(file) => file,
517        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
518            return Ok(ConsoleKey::Absent);
519        }
520        Err(error) => return Err(anyhow!(error).context(format!("open {}", path.display()))),
521    };
522    let metadata = file
523        .metadata()
524        .with_context(|| format!("inspect {}", path.display()))?;
525    if !metadata.is_file() {
526        bail!("{} is not a regular file", path.display());
527    }
528    if metadata.len() > MAX_CLAUDE_CONFIG_BYTES {
529        return Ok(ConsoleKey::TooLarge);
530    }
531    let reader = std::io::BufReader::new(file.take(MAX_CLAUDE_CONFIG_BYTES));
532    match serde_json::from_reader::<_, ClaudeConfigFile>(reader) {
533        Ok(config) => Ok(
534            if config
535                .primary_api_key
536                .as_deref()
537                .is_some_and(|key| !key.trim().is_empty())
538            {
539                ConsoleKey::Present
540            } else {
541                ConsoleKey::Absent
542            },
543        ),
544        Err(_) => Ok(ConsoleKey::Unparseable),
545    }
546}
547
548fn claude_account_status(credentials: &Path, config: &Path, _home: &Path) -> Result<StoredStatus> {
549    let Some(text) = read_bounded(credentials)? else {
550        return Ok(StoredStatus::missing(vec![
551            "not signed in (run scv agents login claude-ai)".into(),
552        ]));
553    };
554    let value: serde_json::Value = match serde_json::from_str(&text) {
555        Ok(value) => value,
556        Err(_) => {
557            return Ok(StoredStatus::missing(vec![
558                "blocked: Claude credentials file is not valid JSON".into(),
559            ]));
560        }
561    };
562    match console_key(config)? {
563        ConsoleKey::Present => {
564            return Ok(StoredStatus::missing(vec![
565                "blocked: a Console API key is in this home".into(),
566            ]));
567        }
568        ConsoleKey::Unparseable => {
569            return Ok(StoredStatus::missing(vec![
570                "blocked: Claude config file is not valid JSON".into(),
571            ]));
572        }
573        ConsoleKey::TooLarge => {
574            return Ok(StoredStatus::missing(vec![
575                "blocked: Claude config file is too large to confirm it has no Console API key"
576                    .into(),
577            ]));
578        }
579        ConsoleKey::Absent => {}
580    }
581    let Some(oauth) = value
582        .get("claudeAiOauth")
583        .and_then(serde_json::Value::as_object)
584    else {
585        return Ok(StoredStatus::missing(vec![
586            "not signed in (run scv agents login claude-ai)".into(),
587        ]));
588    };
589    let usable = oauth
590        .get("refreshToken")
591        .and_then(serde_json::Value::as_str)
592        .is_some_and(|token| !token.trim().is_empty())
593        && oauth
594            .get("scopes")
595            .and_then(serde_json::Value::as_array)
596            .is_some_and(|scopes| {
597                scopes
598                    .iter()
599                    .any(|scope| scope.as_str() == Some("user:inference"))
600            });
601    if !usable {
602        return Ok(StoredStatus::missing(vec![
603            "not signed in (run scv agents login claude-ai)".into(),
604        ]));
605    }
606    let plan = oauth
607        .get("subscriptionType")
608        .and_then(serde_json::Value::as_str)
609        .filter(|plan| ["free", "pro", "max", "team", "enterprise"].contains(plan))
610        .unwrap_or("unknown");
611    Ok(StoredStatus::ready(format!(
612        "signed in (Claude subscription, {plan})"
613    )))
614}
615
616fn write_private(path: &Path, contents: &str) -> Result<()> {
617    scv_client::fs::replace_private(path, contents.as_bytes())
618        .with_context(|| format!("replace {}", path.display()))
619}
620
621/// Longest API key or endpoint field SCV accepts.
622pub const MAX_FIELD_BYTES: usize = 4096;
623
624/// The pi provider id SCV writes for an OpenAI-compatible endpoint.
625pub(crate) const PI_PROVIDER: &str = "scv";
626
627/// Which OpenAI wire protocol an endpoint speaks.
628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
629pub enum WireApi {
630    Responses,
631    ChatCompletions,
632}
633
634impl WireApi {
635    fn pi_api(self) -> &'static str {
636        match self {
637            Self::Responses => "openai-responses",
638            Self::ChatCompletions => "openai-completions",
639        }
640    }
641}
642
643/// An OpenAI-compatible endpoint for pi, without its key.
644#[derive(Debug, Clone)]
645pub struct Endpoint {
646    pub base_url: String,
647    pub api: WireApi,
648    pub model: String,
649}
650
651/// Check a key or secret SCV is about to store: one line of at most
652/// [`MAX_FIELD_BYTES`] printable characters, without quotes or backslashes.
653pub fn validate_secret(secret: &str) -> Result<()> {
654    if secret.is_empty() {
655        bail!("no key entered");
656    }
657    if secret.len() > MAX_FIELD_BYTES
658        || secret
659            .chars()
660            .any(|c| c.is_whitespace() || c.is_control() || c == '"' || c == '\\')
661    {
662        bail!("the key must be one line of at most {MAX_FIELD_BYTES} printable characters");
663    }
664    Ok(())
665}
666
667/// Store an API key in `store`, inside the adapter `home`.
668pub fn store_key(store: KeyStore, home: &Path, key: &str) -> Result<Vec<String>> {
669    validate_secret(key)?;
670    match store {
671        KeyStore::ClaudeApiKey { key: relative, .. } => {
672            let path = home.join(relative);
673            create_private_dirs(home, &path)?;
674            write_private(&path, key)?;
675            Ok(vec![format!(
676                "Stored the Claude API key in {}",
677                display(&path, home)
678            )])
679        }
680        KeyStore::ClaudeAccount { .. } => {
681            bail!(
682                "Claude subscription credentials are owned by Claude Code; run `claude auth login --claudeai`"
683            )
684        }
685        KeyStore::DshRefs { path, variable } => {
686            let path = home.join(path);
687            create_private_dirs(home, &path)?;
688            // serde_json quoting is a valid YAML double-quoted scalar.
689            let quoted = serde_json::to_string(key)?;
690            write_private(
691                &path,
692                &format!("version: 1\n\nrefs:\n  {variable}: {quoted}\n"),
693            )?;
694            Ok(vec![format!(
695                "Stored the API key as {variable} in {}",
696                display(&path, home)
697            )])
698        }
699        KeyStore::Grok { .. } | KeyStore::Pi { .. } => {
700            bail!("this agent signs in with its own login, not a stored key")
701        }
702        KeyStore::Scv { .. } => {
703            bail!("the nested SCV uses SCV's own provider: run `scv agents import scv`")
704        }
705    }
706}
707
708/// Describe what `store` holds, never printing a secret.
709pub fn stored_status(store: KeyStore, home: &Path) -> Result<StoredStatus> {
710    match store {
711        KeyStore::ClaudeApiKey { key, credentials } => {
712            claude_api_status(&home.join(key), &home.join(credentials), home)
713        }
714        KeyStore::ClaudeAccount {
715            credentials,
716            config,
717        } => claude_account_status(&home.join(credentials), &home.join(config), home),
718        KeyStore::Grok { auth, config } => grok_status(&home.join(auth), &home.join(config), home),
719        KeyStore::DshRefs { path, variable } => {
720            let path = home.join(path);
721            let stored = read_bounded(&path)?.is_some_and(|text| {
722                text.lines().any(|line| {
723                    line.trim_start()
724                        .strip_prefix(variable)
725                        .and_then(|rest| rest.strip_prefix(':'))
726                        .is_some_and(|value| !matches!(value.trim(), "" | "\"\"" | "''"))
727                })
728            });
729            Ok(if stored {
730                StoredStatus::ready(format!("API key stored as {variable}"))
731            } else {
732                StoredStatus::missing(vec!["not signed in".into()])
733            })
734        }
735        KeyStore::Pi { dir } => pi_status(&home.join(dir)),
736        KeyStore::Scv { config } => scv_child_status(&home.join(config)),
737    }
738}
739
740/// Remove the credentials SCV can see in `store`.
741pub fn remove_stored(store: KeyStore, home: &Path) -> Result<Vec<String>> {
742    match store {
743        KeyStore::ClaudeApiKey { key, .. } => {
744            let path = home.join(key);
745            Ok(vec![if remove_if_present(&path)? {
746                format!("Removed {}", display(&path, home))
747            } else {
748                "Nothing stored".into()
749            }])
750        }
751        KeyStore::ClaudeAccount { .. } => {
752            bail!(
753                "Claude subscription credentials are owned by Claude Code; run `claude auth logout`"
754            )
755        }
756        KeyStore::Grok { auth: path, .. }
757        | KeyStore::DshRefs { path, .. }
758        | KeyStore::Scv { config: path } => {
759            let path = home.join(path);
760            Ok(vec![if remove_if_present(&path)? {
761                format!("Removed {}", display(&path, home))
762            } else {
763                "Nothing stored".into()
764            }])
765        }
766        KeyStore::Pi { dir } => {
767            let dir = home.join(dir);
768            let mut notes = Vec::new();
769            if remove_if_present(&dir.join("auth.json"))? {
770                notes.push("Removed pi's stored sign-ins (auth.json)".into());
771            }
772            let models = dir.join("models.json");
773            if let Some(mut object) = read_json_object(&models)?
774                && let Some(providers) = object
775                    .get_mut("providers")
776                    .and_then(serde_json::Value::as_object_mut)
777                && providers.remove(PI_PROVIDER).is_some()
778            {
779                write_json(&models, &object)?;
780                notes.push(format!(
781                    "Removed the {PI_PROVIDER} endpoint from models.json"
782                ));
783            }
784            let settings = dir.join("settings.json");
785            if let Some(mut object) = read_json_object(&settings)?
786                && object
787                    .get("defaultProvider")
788                    .and_then(serde_json::Value::as_str)
789                    == Some(PI_PROVIDER)
790            {
791                object.remove("defaultProvider");
792                object.remove("defaultModel");
793                write_json(&settings, &object)?;
794                notes.push("Cleared pi's default model".into());
795            }
796            if notes.is_empty() {
797                notes.push("Nothing stored".into());
798            }
799            Ok(notes)
800        }
801    }
802}
803
804/// The provider profile name `scv agents import scv` writes for the nested SCV.
805pub(crate) const SCV_CHILD_PROVIDER: &str = "scv";
806
807/// The parts of SCV's own provider that the nested SCV copies.
808pub struct ScvChildProvider<'a> {
809    pub wire_api: &'a str,
810    pub model: &'a str,
811    pub base_url: &'a str,
812    pub timeout_seconds: u64,
813    pub headers: &'a std::collections::HashMap<String, scv_client::Secret>,
814    /// Offer the endpoint's hosted web search, as SCV's own config does.
815    pub hosted_web_search: bool,
816}
817
818/// Write the nested SCV's `config.toml` in `home` (mode 0600): SCV's own
819/// provider as profile `scv` (`SCV_CHILD_PROVIDER`), with `key` stored in
820/// the file because delegated agents never inherit key variables. Other
821/// settings already in the file are kept. Returns display lines without the
822/// key.
823pub fn configure_scv_child(
824    home: &Path,
825    provider: &ScvChildProvider<'_>,
826    key: &str,
827) -> Result<Vec<String>> {
828    validate_secret(key)?;
829    let base_url = validate_base_url(provider.base_url)?;
830    if !valid_model_id(provider.model) {
831        bail!("invalid model id {:?}", provider.model);
832    }
833    let path = home.join("config.toml");
834    create_private_dirs(home, &path)?;
835    let mut table: toml::Table = match read_bounded(&path)? {
836        Some(text) => text
837            .parse()
838            .with_context(|| format!("parse existing {}", display(&path, home)))?,
839        None => toml::Table::new(),
840    };
841    let mut selection = toml::Table::new();
842    selection.insert("active".into(), SCV_CHILD_PROVIDER.into());
843    table.insert("provider".into(), selection.into());
844    let mut profile = toml::Table::new();
845    profile.insert("kind".into(), "openai-compatible".into());
846    profile.insert("wire_api".into(), provider.wire_api.into());
847    profile.insert("model".into(), provider.model.into());
848    profile.insert("base_url".into(), base_url.clone().into());
849    profile.insert("api_key".into(), key.into());
850    profile.insert(
851        "timeout_seconds".into(),
852        i64::try_from(provider.timeout_seconds)
853            .unwrap_or(i64::MAX)
854            .into(),
855    );
856    if !provider.headers.is_empty() {
857        let headers: toml::Table = provider
858            .headers
859            .iter()
860            .map(|(name, value)| (name.clone(), value.expose().into()))
861            .collect();
862        profile.insert("headers".into(), headers.into());
863    }
864    let providers = table
865        .entry("providers")
866        .or_insert_with(|| toml::Table::new().into());
867    let Some(providers) = providers.as_table_mut() else {
868        bail!(
869            "{} has a `providers` value that is not a table",
870            display(&path, home)
871        );
872    };
873    providers.insert(SCV_CHILD_PROVIDER.into(), profile.into());
874    if provider.hosted_web_search {
875        let web = table
876            .entry("web")
877            .or_insert_with(|| toml::Table::new().into());
878        let Some(web) = web.as_table_mut() else {
879            bail!(
880                "{} has a `web` value that is not a table",
881                display(&path, home)
882            );
883        };
884        web.insert("search".into(), "provider".into());
885    }
886    let text = toml::to_string(&table).context("encode the nested SCV config")?;
887    text.parse::<toml::Table>()
888        .context("the nested SCV config did not round-trip")?;
889    write_private(&path, &text)?;
890    let mut lines = vec![
891        format!(
892            "Wrote {} (mode 0600): provider {SCV_CHILD_PROVIDER:?}, model {:?} at {}",
893            display(&path, home),
894            provider.model,
895            host(&base_url)
896        ),
897        "The API key is stored in that file and not shown".into(),
898    ];
899    if provider.hosted_web_search {
900        lines.push("Hosted web search is on, as in SCV's own config".into());
901    }
902    Ok(lines)
903}
904
905/// What the nested SCV's `config.toml` provides, never printing its key.
906fn scv_child_status(path: &Path) -> Result<StoredStatus> {
907    let Some(text) = read_bounded(path)? else {
908        return Ok(StoredStatus::missing(vec![
909            "not configured: run `scv agents import scv`".into(),
910        ]));
911    };
912    let Ok(table) = text.parse::<toml::Table>() else {
913        return Ok(StoredStatus::missing(vec![
914            "config.toml is not valid TOML".into(),
915        ]));
916    };
917    let active = table
918        .get("provider")
919        .and_then(|provider| provider.get("active"))
920        .and_then(toml::Value::as_str);
921    let profile = active.and_then(|active| {
922        table
923            .get("providers")
924            .and_then(|providers| providers.get(active))
925            .and_then(toml::Value::as_table)
926    });
927    let (Some(active), Some(profile)) = (active, profile) else {
928        return Ok(StoredStatus::missing(vec![
929            "no active provider: run `scv agents import scv`".into(),
930        ]));
931    };
932    let text = |key: &str| profile.get(key).and_then(toml::Value::as_str);
933    let keyed = text("api_key").is_some_and(|key| !key.trim().is_empty());
934    let location = format!(
935        "provider {active:?}: model {:?} at {}",
936        text("model").unwrap_or("unset"),
937        text("base_url").map_or_else(|| "no base URL".into(), host)
938    );
939    Ok(if keyed {
940        StoredStatus::ready(format!("{location}, API key stored"))
941    } else {
942        StoredStatus::missing(vec![format!(
943            "{location}, no stored API key (a key variable is not inherited): run `scv agents import scv`"
944        )])
945    })
946}
947
948/// Point pi at an OpenAI-compatible endpoint as provider `scv`
949/// (`PI_PROVIDER`) and make it pi's default, storing the key in pi's
950/// `auth.json`.
951pub fn configure_pi_endpoint(dir: &Path, endpoint: &Endpoint, key: &str) -> Result<Vec<String>> {
952    validate_secret(key)?;
953    let base_url = validate_base_url(&endpoint.base_url)?;
954    if !valid_model_id(&endpoint.model) {
955        bail!("invalid model id {:?}", endpoint.model);
956    }
957    create_private_dirs(dir, &dir.join("auth.json"))?;
958    // Validate every existing file before changing any of them.
959    let mut models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
960    let mut auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
961    let mut settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
962
963    let providers = models
964        .entry("providers")
965        .or_insert_with(|| serde_json::json!({}));
966    let providers = providers
967        .as_object_mut()
968        .ok_or_else(|| anyhow!("pi models.json has a non-object \"providers\""))?;
969    let mut provider = serde_json::json!({
970        "baseUrl": base_url,
971        "api": endpoint.api.pi_api(),
972        "models": [{"id": endpoint.model}],
973    });
974    if endpoint.api == WireApi::Responses {
975        // pi's default OpenAI affinity header is `session_id`; proxies that
976        // reject underscores in header names answer it with HTTP 520
977        // (verified against a relay). `x-client-request-id` still goes out.
978        provider["compat"] = serde_json::json!({"sessionAffinityFormat": "openai-nosession"});
979    }
980    providers.insert(PI_PROVIDER.into(), provider);
981    auth.insert(
982        PI_PROVIDER.into(),
983        serde_json::json!({"type": "api_key", "key": key}),
984    );
985    settings.insert("defaultProvider".into(), PI_PROVIDER.into());
986    settings.insert("defaultModel".into(), endpoint.model.clone().into());
987
988    write_json(&dir.join("models.json"), &models)?;
989    write_json(&dir.join("auth.json"), &auth)?;
990    write_json(&dir.join("settings.json"), &settings)?;
991    Ok(vec![
992        format!(
993            "Configured pi provider {PI_PROVIDER:?}: {} at {}, model {:?}",
994            endpoint.api.pi_api(),
995            host(&base_url),
996            endpoint.model
997        ),
998        "Stored its API key in pi's auth.json (mode 0600) and made it pi's default".into(),
999    ])
1000}
1001
1002fn pi_status(dir: &Path) -> Result<StoredStatus> {
1003    let settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
1004    let auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
1005    let models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
1006    let mut lines = Vec::new();
1007    let text = |object: &serde_json::Map<String, serde_json::Value>, key: &str| {
1008        object
1009            .get(key)
1010            .and_then(serde_json::Value::as_str)
1011            .map(ToOwned::to_owned)
1012    };
1013    let provider = text(&settings, "defaultProvider");
1014    if let Some(provider) = &provider {
1015        let endpoint = models
1016            .get("providers")
1017            .and_then(|providers| providers.get(provider))
1018            .and_then(serde_json::Value::as_object);
1019        let location = endpoint
1020            .map(|endpoint| {
1021                format!(
1022                    " ({} at {})",
1023                    text(endpoint, "api").unwrap_or_else(|| "api unset".into()),
1024                    text(endpoint, "baseUrl")
1025                        .map_or_else(|| "no base URL".into(), |url| host(&url))
1026                )
1027            })
1028            .unwrap_or_default();
1029        lines.push(format!(
1030            "default provider {provider:?}{location}, model {:?}",
1031            text(&settings, "defaultModel").unwrap_or_else(|| "unset".into())
1032        ));
1033    }
1034    let mut signed_in: Vec<&String> = auth.keys().collect();
1035    signed_in.sort();
1036    let ready = !signed_in.is_empty();
1037    if ready {
1038        let names: Vec<String> = signed_in.iter().map(|name| format!("{name:?}")).collect();
1039        lines.push(format!("stored sign-ins: {}", names.join(", ")));
1040    } else {
1041        lines.push("not signed in".into());
1042    }
1043    Ok(StoredStatus { ready, lines })
1044}
1045
1046fn validate_base_url(value: &str) -> Result<String> {
1047    let value = value.trim().trim_end_matches('/');
1048    let rest = value
1049        .strip_prefix("https://")
1050        .or_else(|| value.strip_prefix("http://"))
1051        .ok_or_else(|| anyhow!("the base URL must start with https:// or http://"))?;
1052    let authority = rest.split('/').next().unwrap_or_default();
1053    if authority.is_empty()
1054        || authority.contains('@')
1055        || value.len() > MAX_FIELD_BYTES
1056        || value
1057            .chars()
1058            .any(|c| c.is_whitespace() || c.is_control() || matches!(c, '?' | '#'))
1059    {
1060        bail!("the base URL must be a plain http(s) URL without credentials, query, or fragment");
1061    }
1062    Ok(value.to_owned())
1063}
1064
1065/// The host of a validated URL, for display.
1066fn host(url: &str) -> String {
1067    url.split("://")
1068        .nth(1)
1069        .and_then(|rest| rest.split('/').next())
1070        .unwrap_or(url)
1071        .to_owned()
1072}
1073
1074fn valid_model_id(model: &str) -> bool {
1075    !model.is_empty()
1076        && model.len() <= 128
1077        && !model.starts_with(['-', '@'])
1078        && model
1079            .chars()
1080            .all(|c| c.is_ascii_alphanumeric() || "._:/@[]-".contains(c))
1081}
1082
1083fn display(path: &Path, home: &Path) -> String {
1084    path.strip_prefix(home).map_or_else(
1085        |_| path.display().to_string(),
1086        |relative| relative.display().to_string(),
1087    )
1088}
1089
1090fn read_json_object(path: &Path) -> Result<Option<serde_json::Map<String, serde_json::Value>>> {
1091    let Some(text) = read_bounded(path)? else {
1092        return Ok(None);
1093    };
1094    if text.trim().is_empty() {
1095        return Ok(Some(serde_json::Map::new()));
1096    }
1097    match serde_json::from_str(&text) {
1098        Ok(serde_json::Value::Object(object)) => Ok(Some(object)),
1099        // Never echo the content: these files hold keys.
1100        _ => bail!("{} is not a JSON object", path.display()),
1101    }
1102}
1103
1104fn write_json(path: &Path, object: &serde_json::Map<String, serde_json::Value>) -> Result<()> {
1105    write_private(
1106        path,
1107        &format!("{}\n", serde_json::to_string_pretty(object)?),
1108    )
1109}
1110
1111fn remove_if_present(path: &Path) -> Result<bool> {
1112    match std::fs::remove_file(path) {
1113        Ok(()) => Ok(true),
1114        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1115        Err(error) => Err(anyhow!(error).context(format!("remove {}", path.display()))),
1116    }
1117}
1118
1119/// Create the directories between `root` and `file` with mode 0700.
1120fn create_private_dirs(root: &Path, file: &Path) -> Result<()> {
1121    let parent = file
1122        .parent()
1123        .ok_or_else(|| anyhow!("{} has no parent", file.display()))?;
1124    std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
1125    use std::os::unix::fs::PermissionsExt as _;
1126    let mut dir = parent;
1127    loop {
1128        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
1129        match dir.parent() {
1130            Some(next) if next.starts_with(root) && next != root => dir = next,
1131            _ => break,
1132        }
1133    }
1134    Ok(())
1135}
1136
1137#[cfg(test)]
1138mod tests;