Skip to main content

scv_server/
agents.rs

1//! Credentials for the native agents SCV delegates to, always inside SCV's
2//! private adapter homes: importing a user's own Codex setup, and the
3//! API-key and endpoint stores SCV writes in an agent CLI's native format.
4
5use std::io::{Read as _, Write as _};
6use std::path::Path;
7
8use anyhow::{Context, Result, anyhow, bail};
9use scv_tools::adapters::KeyStore;
10
11const MAX_IMPORT_BYTES: u64 = 1024 * 1024;
12
13enum Auth {
14    /// An API key, which is static and safe to hold in two homes.
15    ApiKey,
16    /// A ChatGPT sign-in, whose rotating refresh token must stay in one home.
17    Session,
18    None,
19}
20
21/// Copy `config.toml` and an API-key `auth.json` from a Codex home into
22/// `destination`, returning display lines that never contain secret values.
23/// Both files are validated before either is written.
24pub fn import_codex(source: &Path, destination: &Path) -> Result<Vec<String>> {
25    let source = std::fs::canonicalize(source)
26        .with_context(|| format!("resolve Codex home {}", source.display()))?;
27    let destination = std::fs::canonicalize(destination)
28        .with_context(|| format!("resolve SCV Codex home {}", destination.display()))?;
29    if source == destination {
30        bail!(
31            "{} is already SCV's Codex adapter home; pass your own Codex home with --from",
32            source.display()
33        );
34    }
35    let config = read_bounded(&source.join("config.toml"))?;
36    let auth = read_bounded(&source.join("auth.json"))?;
37    if config.is_none() && auth.is_none() {
38        bail!("no config.toml or auth.json in {}", source.display());
39    }
40    let table = config
41        .as_deref()
42        .map(|text| text.parse::<toml::Table>())
43        .transpose()
44        .context("parse Codex config.toml")?;
45    let auth_kind = auth.as_deref().map(classify_auth).transpose()?;
46
47    let mut notes = Vec::new();
48    if let (Some(text), Some(table)) = (&config, &table) {
49        write_private(&destination.join("config.toml"), text)?;
50        notes.push(format!("Copied config.toml{}", describe(table)));
51        notes.extend(config_notes(table));
52    }
53    match (&auth, auth_kind) {
54        (Some(text), Some(Auth::ApiKey)) => {
55            write_private(&destination.join("auth.json"), text)?;
56            notes.push("Copied the API-key sign-in from auth.json".into());
57        }
58        (Some(_), Some(Auth::Session)) => notes.push(
59            "Skipped auth.json: a ChatGPT sign-in's refresh token must not be shared; \
60             sign SCV in separately with `scv agents login codex`"
61                .into(),
62        ),
63        (Some(_), Some(Auth::None)) => notes.push("Skipped auth.json: it holds no API key".into()),
64        _ => {}
65    }
66    Ok(notes)
67}
68
69fn read_bounded(path: &Path) -> Result<Option<String>> {
70    let file = match std::fs::File::open(path) {
71        Ok(file) => file,
72        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
73        Err(error) => return Err(anyhow!(error).context(format!("open {}", path.display()))),
74    };
75    if !file.metadata()?.is_file() {
76        bail!("{} is not a regular file", path.display());
77    }
78    let mut text = String::new();
79    file.take(MAX_IMPORT_BYTES + 1)
80        .read_to_string(&mut text)
81        .with_context(|| format!("read {}", path.display()))?;
82    if text.len() as u64 > MAX_IMPORT_BYTES {
83        bail!("{} exceeds 1 MiB", path.display());
84    }
85    Ok(Some(text))
86}
87
88fn classify_auth(text: &str) -> Result<Auth> {
89    let value: serde_json::Value =
90        serde_json::from_str(text).map_err(|_| anyhow!("Codex auth.json is not valid JSON"))?;
91    let object = value
92        .as_object()
93        .ok_or_else(|| anyhow!("Codex auth.json is not a JSON object"))?;
94    if object.get("tokens").is_some_and(|tokens| !tokens.is_null()) {
95        return Ok(Auth::Session);
96    }
97    let has_key = object
98        .get("OPENAI_API_KEY")
99        .and_then(serde_json::Value::as_str)
100        .is_some_and(|key| !key.trim().is_empty());
101    Ok(if has_key { Auth::ApiKey } else { Auth::None })
102}
103
104/// Non-secret identifying settings, debug-quoted so they are terminal-safe.
105fn describe(table: &toml::Table) -> String {
106    let fields: Vec<String> = ["model_provider", "model"]
107        .into_iter()
108        .filter_map(|key| Some(format!("{key} {:?}", table.get(key)?.as_str()?)))
109        .collect();
110    if fields.is_empty() {
111        String::new()
112    } else {
113        format!(" ({})", fields.join(", "))
114    }
115}
116
117fn config_notes(table: &toml::Table) -> Vec<String> {
118    let mut notes = Vec::new();
119    let policy: Vec<String> = ["sandbox_mode", "approval_policy"]
120        .into_iter()
121        .filter_map(|key| Some(format!("{key} {:?}", table.get(key)?.as_str()?)))
122        .collect();
123    if !policy.is_empty() {
124        notes.push(format!(
125            "Delegated Codex runs also use {}",
126            policy.join(" and ")
127        ));
128    }
129    let providers = table
130        .get("model_providers")
131        .and_then(toml::Value::as_table)
132        .into_iter()
133        .flatten();
134    for (name, provider) in providers {
135        let Some(variable) = provider.get("env_key").and_then(toml::Value::as_str) else {
136            continue;
137        };
138        notes.push(
139            if scv_tools::adapters::is_removed_agent_variable(std::ffi::OsStr::new(variable)) {
140                format!(
141                    "Warning: provider {name:?} reads its key from ${variable}, which SCV \
142                     removes from delegated agents; keep the key in auth.json \
143                     (requires_openai_auth) or experimental_bearer_token instead"
144                )
145            } else {
146                format!(
147                    "Note: provider {name:?} reads its key from ${variable}; the SCV daemon's \
148                     environment must provide it (the user service does not load your shell profile)"
149                )
150            },
151        );
152    }
153    notes
154}
155
156fn write_private(path: &Path, contents: &str) -> Result<()> {
157    let parent = path
158        .parent()
159        .ok_or_else(|| anyhow!("import path has no parent"))?;
160    // Named temporary files are created with mode 0600.
161    let mut temporary =
162        tempfile::NamedTempFile::new_in(parent).context("create temporary import file")?;
163    temporary
164        .write_all(contents.as_bytes())
165        .context("write imported file")?;
166    temporary.as_file().sync_all()?;
167    temporary
168        .persist(path)
169        .map_err(|error| anyhow!("replace {}: {}", path.display(), error.error))?;
170    Ok(())
171}
172
173/// Longest API key or endpoint field SCV accepts.
174const MAX_FIELD_BYTES: usize = 4096;
175
176/// The pi provider id SCV writes for an OpenAI-compatible endpoint.
177pub const PI_PROVIDER: &str = "scv";
178
179/// Which OpenAI wire protocol an endpoint speaks.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum WireApi {
182    Responses,
183    ChatCompletions,
184}
185
186impl WireApi {
187    fn pi_api(self) -> &'static str {
188        match self {
189            Self::Responses => "openai-responses",
190            Self::ChatCompletions => "openai-completions",
191        }
192    }
193}
194
195/// An OpenAI-compatible endpoint for pi, without its key.
196#[derive(Debug, Clone)]
197pub struct Endpoint {
198    pub base_url: String,
199    pub api: WireApi,
200    pub model: String,
201}
202
203/// Read a secret from the terminal without echo, or from piped stdin.
204pub fn read_secret(prompt: &str) -> Result<String> {
205    use std::io::{BufRead as _, IsTerminal as _};
206    let stdin = std::io::stdin();
207    let mut line = String::new();
208    if stdin.is_terminal() {
209        eprint!("{prompt}: ");
210        std::io::stderr().flush().ok();
211        let _echo = EchoOff::new()?;
212        stdin.lock().read_line(&mut line)?;
213        eprintln!();
214    } else {
215        stdin
216            .lock()
217            .take(MAX_FIELD_BYTES as u64 + 2)
218            .read_line(&mut line)?;
219    }
220    let secret = line.trim().to_owned();
221    validate_secret(&secret)?;
222    Ok(secret)
223}
224
225/// Terminal echo disabled for the guard's lifetime.
226struct EchoOff(libc::termios);
227
228impl EchoOff {
229    fn new() -> Result<Self> {
230        let mut termios = std::mem::MaybeUninit::<libc::termios>::uninit();
231        // SAFETY: tcgetattr fills the termios struct for a valid descriptor.
232        if unsafe { libc::tcgetattr(libc::STDIN_FILENO, termios.as_mut_ptr()) } != 0 {
233            bail!(
234                "read terminal settings: {}",
235                std::io::Error::last_os_error()
236            );
237        }
238        // SAFETY: tcgetattr succeeded, so the struct is initialized.
239        let original = unsafe { termios.assume_init() };
240        let mut silent = original;
241        silent.c_lflag &= !libc::ECHO;
242        // SAFETY: a valid descriptor and a termios derived from its own settings.
243        if unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &silent) } != 0 {
244            bail!("disable terminal echo: {}", std::io::Error::last_os_error());
245        }
246        Ok(Self(original))
247    }
248}
249
250impl Drop for EchoOff {
251    fn drop(&mut self) {
252        // SAFETY: restores the settings read from the same descriptor.
253        unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &self.0) };
254    }
255}
256
257fn validate_secret(secret: &str) -> Result<()> {
258    if secret.is_empty() {
259        bail!("no key entered");
260    }
261    if secret.len() > MAX_FIELD_BYTES
262        || secret
263            .chars()
264            .any(|c| c.is_whitespace() || c.is_control() || c == '"' || c == '\\')
265    {
266        bail!("the key must be one line of at most {MAX_FIELD_BYTES} printable characters");
267    }
268    Ok(())
269}
270
271/// Store an API key in `store`, inside the adapter `home`.
272pub fn store_key(store: KeyStore, home: &Path, key: &str) -> Result<Vec<String>> {
273    validate_secret(key)?;
274    match store {
275        KeyStore::DshRefs { path, variable } => {
276            let path = home.join(path);
277            create_private_dirs(home, &path)?;
278            // serde_json quoting is a valid YAML double-quoted scalar.
279            let quoted = serde_json::to_string(key)?;
280            write_private(
281                &path,
282                &format!("version: 1\n\nrefs:\n  {variable}: {quoted}\n"),
283            )?;
284            Ok(vec![format!(
285                "Stored the API key as {variable} in {}",
286                display(&path, home)
287            )])
288        }
289        KeyStore::JsonEntries(_) | KeyStore::Pi { .. } => {
290            bail!("this agent signs in with its own login, not a stored key")
291        }
292    }
293}
294
295/// Describe what `store` holds, never printing a secret.
296pub fn stored_status(store: KeyStore, home: &Path) -> Result<(bool, Vec<String>)> {
297    match store {
298        KeyStore::JsonEntries(path) => {
299            let path = home.join(path);
300            let entries = read_json_object(&path)?
301                .map(|object| object.values().filter(|value| !value.is_null()).count())
302                .unwrap_or(0);
303            Ok(if entries > 0 {
304                (true, vec![format!("signed in ({})", display(&path, home))])
305            } else {
306                (false, vec!["not signed in".into()])
307            })
308        }
309        KeyStore::DshRefs { path, variable } => {
310            let path = home.join(path);
311            let stored = read_bounded(&path)?.is_some_and(|text| {
312                text.lines().any(|line| {
313                    line.trim_start()
314                        .strip_prefix(variable)
315                        .and_then(|rest| rest.strip_prefix(':'))
316                        .is_some_and(|value| !matches!(value.trim(), "" | "\"\"" | "''"))
317                })
318            });
319            Ok(if stored {
320                (true, vec![format!("API key stored as {variable}")])
321            } else {
322                (false, vec!["not signed in".into()])
323            })
324        }
325        KeyStore::Pi { dir } => pi_status(&home.join(dir)),
326    }
327}
328
329/// Remove the credentials SCV can see in `store`.
330pub fn remove_stored(store: KeyStore, home: &Path) -> Result<Vec<String>> {
331    match store {
332        KeyStore::JsonEntries(path) | KeyStore::DshRefs { path, .. } => {
333            let path = home.join(path);
334            Ok(vec![if remove_if_present(&path)? {
335                format!("Removed {}", display(&path, home))
336            } else {
337                "Nothing stored".into()
338            }])
339        }
340        KeyStore::Pi { dir } => {
341            let dir = home.join(dir);
342            let mut notes = Vec::new();
343            if remove_if_present(&dir.join("auth.json"))? {
344                notes.push("Removed pi's stored sign-ins (auth.json)".into());
345            }
346            let models = dir.join("models.json");
347            if let Some(mut object) = read_json_object(&models)?
348                && let Some(providers) = object
349                    .get_mut("providers")
350                    .and_then(serde_json::Value::as_object_mut)
351                && providers.remove(PI_PROVIDER).is_some()
352            {
353                write_json(&models, &object)?;
354                notes.push(format!(
355                    "Removed the {PI_PROVIDER} endpoint from models.json"
356                ));
357            }
358            let settings = dir.join("settings.json");
359            if let Some(mut object) = read_json_object(&settings)?
360                && object
361                    .get("defaultProvider")
362                    .and_then(serde_json::Value::as_str)
363                    == Some(PI_PROVIDER)
364            {
365                object.remove("defaultProvider");
366                object.remove("defaultModel");
367                write_json(&settings, &object)?;
368                notes.push("Cleared pi's default model".into());
369            }
370            if notes.is_empty() {
371                notes.push("Nothing stored".into());
372            }
373            Ok(notes)
374        }
375    }
376}
377
378/// Point pi at an OpenAI-compatible endpoint as provider [`PI_PROVIDER`]
379/// and make it pi's default, storing the key in pi's `auth.json`.
380pub fn configure_pi_endpoint(dir: &Path, endpoint: &Endpoint, key: &str) -> Result<Vec<String>> {
381    validate_secret(key)?;
382    let base_url = validate_base_url(&endpoint.base_url)?;
383    if !valid_model_id(&endpoint.model) {
384        bail!("invalid model id {:?}", endpoint.model);
385    }
386    create_private_dirs(dir, &dir.join("auth.json"))?;
387    // Validate every existing file before changing any of them.
388    let mut models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
389    let mut auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
390    let mut settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
391
392    let providers = models
393        .entry("providers")
394        .or_insert_with(|| serde_json::json!({}));
395    let providers = providers
396        .as_object_mut()
397        .ok_or_else(|| anyhow!("pi models.json has a non-object \"providers\""))?;
398    let mut provider = serde_json::json!({
399        "baseUrl": base_url,
400        "api": endpoint.api.pi_api(),
401        "models": [{"id": endpoint.model}],
402    });
403    if endpoint.api == WireApi::Responses {
404        // pi's default OpenAI affinity header is `session_id`; proxies that
405        // reject underscores in header names answer it with HTTP 520
406        // (verified against a relay). `x-client-request-id` still goes out.
407        provider["compat"] = serde_json::json!({"sessionAffinityFormat": "openai-nosession"});
408    }
409    providers.insert(PI_PROVIDER.into(), provider);
410    auth.insert(
411        PI_PROVIDER.into(),
412        serde_json::json!({"type": "api_key", "key": key}),
413    );
414    settings.insert("defaultProvider".into(), PI_PROVIDER.into());
415    settings.insert("defaultModel".into(), endpoint.model.clone().into());
416
417    write_json(&dir.join("models.json"), &models)?;
418    write_json(&dir.join("auth.json"), &auth)?;
419    write_json(&dir.join("settings.json"), &settings)?;
420    Ok(vec![
421        format!(
422            "Configured pi provider {PI_PROVIDER:?}: {} at {}, model {:?}",
423            endpoint.api.pi_api(),
424            host(&base_url),
425            endpoint.model
426        ),
427        "Stored its API key in pi's auth.json (mode 0600) and made it pi's default".into(),
428    ])
429}
430
431fn pi_status(dir: &Path) -> Result<(bool, Vec<String>)> {
432    let settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
433    let auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
434    let models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
435    let mut lines = Vec::new();
436    let text = |object: &serde_json::Map<String, serde_json::Value>, key: &str| {
437        object
438            .get(key)
439            .and_then(serde_json::Value::as_str)
440            .map(ToOwned::to_owned)
441    };
442    let provider = text(&settings, "defaultProvider");
443    if let Some(provider) = &provider {
444        let endpoint = models
445            .get("providers")
446            .and_then(|providers| providers.get(provider))
447            .and_then(serde_json::Value::as_object);
448        let location = endpoint
449            .map(|endpoint| {
450                format!(
451                    " ({} at {})",
452                    text(endpoint, "api").unwrap_or_else(|| "api unset".into()),
453                    text(endpoint, "baseUrl")
454                        .map_or_else(|| "no base URL".into(), |url| host(&url))
455                )
456            })
457            .unwrap_or_default();
458        lines.push(format!(
459            "default provider {provider:?}{location}, model {:?}",
460            text(&settings, "defaultModel").unwrap_or_else(|| "unset".into())
461        ));
462    }
463    let mut signed_in: Vec<&String> = auth.keys().collect();
464    signed_in.sort();
465    let ready = !signed_in.is_empty();
466    if ready {
467        let names: Vec<String> = signed_in.iter().map(|name| format!("{name:?}")).collect();
468        lines.push(format!("stored sign-ins: {}", names.join(", ")));
469    } else {
470        lines.push("not signed in".into());
471    }
472    Ok((ready, lines))
473}
474
475fn validate_base_url(value: &str) -> Result<String> {
476    let value = value.trim().trim_end_matches('/');
477    let rest = value
478        .strip_prefix("https://")
479        .or_else(|| value.strip_prefix("http://"))
480        .ok_or_else(|| anyhow!("the base URL must start with https:// or http://"))?;
481    let authority = rest.split('/').next().unwrap_or_default();
482    if authority.is_empty()
483        || authority.contains('@')
484        || value.len() > MAX_FIELD_BYTES
485        || value
486            .chars()
487            .any(|c| c.is_whitespace() || c.is_control() || matches!(c, '?' | '#'))
488    {
489        bail!("the base URL must be a plain http(s) URL without credentials, query, or fragment");
490    }
491    Ok(value.to_owned())
492}
493
494/// The host of a validated URL, for display.
495fn host(url: &str) -> String {
496    url.split("://")
497        .nth(1)
498        .and_then(|rest| rest.split('/').next())
499        .unwrap_or(url)
500        .to_owned()
501}
502
503fn valid_model_id(model: &str) -> bool {
504    !model.is_empty()
505        && model.len() <= 128
506        && !model.starts_with(['-', '@'])
507        && model
508            .chars()
509            .all(|c| c.is_ascii_alphanumeric() || "._:/@[]-".contains(c))
510}
511
512fn display(path: &Path, home: &Path) -> String {
513    path.strip_prefix(home).map_or_else(
514        |_| path.display().to_string(),
515        |relative| relative.display().to_string(),
516    )
517}
518
519fn read_json_object(path: &Path) -> Result<Option<serde_json::Map<String, serde_json::Value>>> {
520    let Some(text) = read_bounded(path)? else {
521        return Ok(None);
522    };
523    if text.trim().is_empty() {
524        return Ok(Some(serde_json::Map::new()));
525    }
526    match serde_json::from_str(&text) {
527        Ok(serde_json::Value::Object(object)) => Ok(Some(object)),
528        // Never echo the content: these files hold keys.
529        _ => bail!("{} is not a JSON object", path.display()),
530    }
531}
532
533fn write_json(path: &Path, object: &serde_json::Map<String, serde_json::Value>) -> Result<()> {
534    write_private(
535        path,
536        &format!("{}\n", serde_json::to_string_pretty(object)?),
537    )
538}
539
540fn remove_if_present(path: &Path) -> Result<bool> {
541    match std::fs::remove_file(path) {
542        Ok(()) => Ok(true),
543        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
544        Err(error) => Err(anyhow!(error).context(format!("remove {}", path.display()))),
545    }
546}
547
548/// Create the directories between `root` and `file` with mode 0700.
549fn create_private_dirs(root: &Path, file: &Path) -> Result<()> {
550    let parent = file
551        .parent()
552        .ok_or_else(|| anyhow!("{} has no parent", file.display()))?;
553    std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
554    use std::os::unix::fs::PermissionsExt as _;
555    let mut dir = parent;
556    loop {
557        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
558        match dir.parent() {
559            Some(next) if next.starts_with(root) && next != root => dir = next,
560            _ => break,
561        }
562    }
563    Ok(())
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    const CONFIG: &str = r#"model_provider = "relay"
571model = "gpt-test"
572sandbox_mode = "danger-full-access"
573approval_policy = "never"
574
575[model_providers.relay]
576base_url = "https://relay.invalid"
577wire_api = "responses"
578requires_openai_auth = true
579experimental_bearer_token = "sk-bearer-secret"
580"#;
581
582    fn homes() -> (tempfile::TempDir, tempfile::TempDir) {
583        (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap())
584    }
585
586    fn mode(path: &Path) -> u32 {
587        use std::os::unix::fs::PermissionsExt;
588        std::fs::metadata(path).unwrap().permissions().mode() & 0o777
589    }
590
591    #[test]
592    fn copies_config_and_api_key_privately_without_printing_secrets() {
593        let (source, destination) = homes();
594        std::fs::write(source.path().join("config.toml"), CONFIG).unwrap();
595        let auth = r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-auth-secret"}"#;
596        std::fs::write(source.path().join("auth.json"), auth).unwrap();
597        let notes = import_codex(source.path(), destination.path()).unwrap();
598        for file in ["config.toml", "auth.json"] {
599            let copied = destination.path().join(file);
600            assert_eq!(
601                std::fs::read_to_string(&copied).unwrap(),
602                std::fs::read_to_string(source.path().join(file)).unwrap()
603            );
604            assert_eq!(mode(&copied), 0o600);
605        }
606        let notes = notes.join("\n");
607        assert!(notes.contains(r#"model_provider "relay", model "gpt-test""#));
608        assert!(notes.contains(r#"sandbox_mode "danger-full-access" and approval_policy "never""#));
609        assert!(notes.contains("API-key sign-in"));
610        assert!(!notes.contains("secret"));
611    }
612
613    #[test]
614    fn never_copies_a_chatgpt_session() {
615        let (source, destination) = homes();
616        std::fs::write(source.path().join("config.toml"), "model = \"m\"\n").unwrap();
617        std::fs::write(
618            source.path().join("auth.json"),
619            r#"{"auth_mode":"chatgpt","OPENAI_API_KEY":null,"tokens":{"refresh_token":"rt"}}"#,
620        )
621        .unwrap();
622        let notes = import_codex(source.path(), destination.path()).unwrap();
623        assert!(destination.path().join("config.toml").is_file());
624        assert!(!destination.path().join("auth.json").exists());
625        assert!(notes.join("\n").contains("scv agents login codex"));
626    }
627
628    #[test]
629    fn env_key_providers_are_flagged() {
630        let (source, destination) = homes();
631        std::fs::write(
632            source.path().join("config.toml"),
633            "[model_providers.a]\nenv_key = \"OPENAI_API_KEY\"\n\
634             [model_providers.b]\nenv_key = \"RELAY_KEY\"\n",
635        )
636        .unwrap();
637        let notes = import_codex(source.path(), destination.path())
638            .unwrap()
639            .join("\n");
640        assert!(notes.contains("$OPENAI_API_KEY, which SCV removes"));
641        assert!(notes.contains("$RELAY_KEY; the SCV daemon's environment must provide it"));
642    }
643
644    const DSH: KeyStore = KeyStore::DshRefs {
645        path: ".dsh/.credentials.yaml",
646        variable: "DEEPSEEK_API_KEY",
647    };
648    const PI: KeyStore = KeyStore::Pi { dir: ".pi/agent" };
649
650    fn endpoint() -> Endpoint {
651        Endpoint {
652            base_url: "https://relay.invalid/v1/".into(),
653            api: WireApi::Responses,
654            model: "gpt-test".into(),
655        }
656    }
657
658    #[test]
659    fn dsh_keys_are_stored_privately_in_its_native_file() {
660        let home = tempfile::tempdir().unwrap();
661        assert!(!stored_status(DSH, home.path()).unwrap().0);
662        let notes = store_key(DSH, home.path(), "sk-dsh-secret").unwrap();
663        assert!(!notes.join("\n").contains("secret"));
664        let path = home.path().join(".dsh/.credentials.yaml");
665        assert_eq!(
666            std::fs::read_to_string(&path).unwrap(),
667            "version: 1\n\nrefs:\n  DEEPSEEK_API_KEY: \"sk-dsh-secret\"\n"
668        );
669        assert_eq!(mode(&path), 0o600);
670        assert_eq!(mode(&home.path().join(".dsh")), 0o700);
671        let (ready, lines) = stored_status(DSH, home.path()).unwrap();
672        assert!(ready);
673        assert!(!lines.join("\n").contains("secret"));
674        for invalid in ["", "two words", "quote\"d", "line\nbreak"] {
675            assert!(store_key(DSH, home.path(), invalid).is_err(), "{invalid:?}");
676        }
677        assert_eq!(
678            remove_stored(DSH, home.path()).unwrap(),
679            ["Removed .dsh/.credentials.yaml"]
680        );
681        assert!(!path.exists());
682        assert!(!stored_status(DSH, home.path()).unwrap().0);
683    }
684
685    #[test]
686    fn json_entry_stores_report_sign_in_without_values() {
687        let home = tempfile::tempdir().unwrap();
688        let store = KeyStore::JsonEntries(".grok/auth.json");
689        assert!(!stored_status(store, home.path()).unwrap().0);
690        std::fs::create_dir(home.path().join(".grok")).unwrap();
691        std::fs::write(home.path().join(".grok/auth.json"), "{}").unwrap();
692        assert!(!stored_status(store, home.path()).unwrap().0);
693        std::fs::write(
694            home.path().join(".grok/auth.json"),
695            r#"{"https://auth.x.ai::id":{"key":"xai-token-secret"}}"#,
696        )
697        .unwrap();
698        let (ready, lines) = stored_status(store, home.path()).unwrap();
699        assert!(ready);
700        assert!(!lines.join("\n").contains("secret"));
701        std::fs::write(home.path().join(".grok/auth.json"), "xai-token-secret").unwrap();
702        let error = stored_status(store, home.path()).unwrap_err().to_string();
703        assert!(!error.contains("secret"), "{error}");
704    }
705
706    #[test]
707    fn pi_endpoints_merge_into_pi_files_as_the_private_default() {
708        let home = tempfile::tempdir().unwrap();
709        let dir = home.path().join(".pi/agent");
710        std::fs::create_dir_all(&dir).unwrap();
711        std::fs::write(
712            dir.join("models.json"),
713            r#"{"providers":{"ollama":{"baseUrl":"http://localhost:11434/v1","api":"openai-completions","models":[{"id":"q"}]}}}"#,
714        )
715        .unwrap();
716        std::fs::write(dir.join("settings.json"), r#"{"theme":"dark"}"#).unwrap();
717        let notes = configure_pi_endpoint(&dir, &endpoint(), "sk-pi-secret").unwrap();
718        let notes = notes.join("\n");
719        assert!(
720            notes.contains("openai-responses at relay.invalid"),
721            "{notes}"
722        );
723        assert!(!notes.contains("secret"));
724
725        let read = |file: &str| -> serde_json::Value {
726            let path = dir.join(file);
727            assert_eq!(mode(&path), 0o600, "{file}");
728            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
729        };
730        let models = read("models.json");
731        assert_eq!(
732            models["providers"]["scv"],
733            serde_json::json!({
734                "baseUrl": "https://relay.invalid/v1",
735                "api": "openai-responses",
736                "models": [{"id": "gpt-test"}],
737                "compat": {"sessionAffinityFormat": "openai-nosession"},
738            })
739        );
740        assert_eq!(models["providers"]["ollama"]["models"][0]["id"], "q");
741        assert_eq!(
742            read("auth.json")["scv"],
743            serde_json::json!({"type": "api_key", "key": "sk-pi-secret"})
744        );
745        let settings = read("settings.json");
746        assert_eq!(settings["defaultProvider"], "scv");
747        assert_eq!(settings["defaultModel"], "gpt-test");
748        assert_eq!(settings["theme"], "dark");
749
750        let (ready, lines) = stored_status(PI, home.path()).unwrap();
751        let lines = lines.join("\n");
752        assert!(ready);
753        assert!(
754            lines.contains(
755                r#"default provider "scv" (openai-responses at relay.invalid), model "gpt-test""#
756            ),
757            "{lines}"
758        );
759        assert!(!lines.contains("secret"));
760
761        let removed = remove_stored(PI, home.path()).unwrap().join("\n");
762        assert!(removed.contains("auth.json"), "{removed}");
763        assert!(!dir.join("auth.json").exists());
764        let models = read("models.json");
765        assert!(models["providers"].get("scv").is_none());
766        assert!(models["providers"].get("ollama").is_some());
767        let settings = read("settings.json");
768        assert!(settings.get("defaultProvider").is_none());
769        assert_eq!(settings["theme"], "dark");
770        assert!(!stored_status(PI, home.path()).unwrap().0);
771    }
772
773    #[test]
774    fn pi_endpoint_input_is_validated_before_any_write() {
775        let home = tempfile::tempdir().unwrap();
776        let dir = home.path().join(".pi/agent");
777        for base_url in [
778            "ftp://relay.invalid",
779            "https://user:pass@relay.invalid",
780            "https://relay.invalid/v1?key=x",
781            "https://",
782        ] {
783            let endpoint = Endpoint {
784                base_url: base_url.into(),
785                ..endpoint()
786            };
787            assert!(
788                configure_pi_endpoint(&dir, &endpoint, "sk").is_err(),
789                "{base_url}"
790            );
791        }
792        let endpoint = Endpoint {
793            model: "--flag".into(),
794            ..endpoint()
795        };
796        assert!(configure_pi_endpoint(&dir, &endpoint, "sk").is_err());
797        assert!(configure_pi_endpoint(&dir, &super::tests::endpoint(), "").is_err());
798        assert!(!dir.exists());
799
800        std::fs::create_dir_all(&dir).unwrap();
801        std::fs::write(dir.join("models.json"), "not json").unwrap();
802        assert!(configure_pi_endpoint(&dir, &super::tests::endpoint(), "sk").is_err());
803        assert!(!dir.join("auth.json").exists());
804        assert!(!dir.join("settings.json").exists());
805    }
806
807    #[test]
808    fn invalid_input_writes_nothing() {
809        let (source, destination) = homes();
810        std::fs::write(source.path().join("config.toml"), "model = \"m\"\n").unwrap();
811        std::fs::write(source.path().join("auth.json"), "not json").unwrap();
812        assert!(import_codex(source.path(), destination.path()).is_err());
813        std::fs::write(source.path().join("config.toml"), "model = ").unwrap();
814        std::fs::remove_file(source.path().join("auth.json")).unwrap();
815        assert!(import_codex(source.path(), destination.path()).is_err());
816        assert_eq!(std::fs::read_dir(destination.path()).unwrap().count(), 0);
817
818        let empty = tempfile::tempdir().unwrap();
819        assert!(import_codex(empty.path(), destination.path()).is_err());
820        assert!(import_codex(destination.path(), destination.path()).is_err());
821    }
822}