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