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