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 }
516}
517
518pub fn stored_status(store: KeyStore, home: &Path) -> Result<(bool, Vec<String>)> {
520 match store {
521 KeyStore::Grok { auth, config } => grok_status(&home.join(auth), &home.join(config), home),
522 KeyStore::DshRefs { path, variable } => {
523 let path = home.join(path);
524 let stored = read_bounded(&path)?.is_some_and(|text| {
525 text.lines().any(|line| {
526 line.trim_start()
527 .strip_prefix(variable)
528 .and_then(|rest| rest.strip_prefix(':'))
529 .is_some_and(|value| !matches!(value.trim(), "" | "\"\"" | "''"))
530 })
531 });
532 Ok(if stored {
533 (true, vec![format!("API key stored as {variable}")])
534 } else {
535 (false, vec!["not signed in".into()])
536 })
537 }
538 KeyStore::Pi { dir } => pi_status(&home.join(dir)),
539 }
540}
541
542pub fn remove_stored(store: KeyStore, home: &Path) -> Result<Vec<String>> {
544 match store {
545 KeyStore::Grok { auth: path, .. } | KeyStore::DshRefs { path, .. } => {
546 let path = home.join(path);
547 Ok(vec![if remove_if_present(&path)? {
548 format!("Removed {}", display(&path, home))
549 } else {
550 "Nothing stored".into()
551 }])
552 }
553 KeyStore::Pi { dir } => {
554 let dir = home.join(dir);
555 let mut notes = Vec::new();
556 if remove_if_present(&dir.join("auth.json"))? {
557 notes.push("Removed pi's stored sign-ins (auth.json)".into());
558 }
559 let models = dir.join("models.json");
560 if let Some(mut object) = read_json_object(&models)?
561 && let Some(providers) = object
562 .get_mut("providers")
563 .and_then(serde_json::Value::as_object_mut)
564 && providers.remove(PI_PROVIDER).is_some()
565 {
566 write_json(&models, &object)?;
567 notes.push(format!(
568 "Removed the {PI_PROVIDER} endpoint from models.json"
569 ));
570 }
571 let settings = dir.join("settings.json");
572 if let Some(mut object) = read_json_object(&settings)?
573 && object
574 .get("defaultProvider")
575 .and_then(serde_json::Value::as_str)
576 == Some(PI_PROVIDER)
577 {
578 object.remove("defaultProvider");
579 object.remove("defaultModel");
580 write_json(&settings, &object)?;
581 notes.push("Cleared pi's default model".into());
582 }
583 if notes.is_empty() {
584 notes.push("Nothing stored".into());
585 }
586 Ok(notes)
587 }
588 }
589}
590
591pub fn configure_pi_endpoint(dir: &Path, endpoint: &Endpoint, key: &str) -> Result<Vec<String>> {
594 validate_secret(key)?;
595 let base_url = validate_base_url(&endpoint.base_url)?;
596 if !valid_model_id(&endpoint.model) {
597 bail!("invalid model id {:?}", endpoint.model);
598 }
599 create_private_dirs(dir, &dir.join("auth.json"))?;
600 let mut models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
602 let mut auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
603 let mut settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
604
605 let providers = models
606 .entry("providers")
607 .or_insert_with(|| serde_json::json!({}));
608 let providers = providers
609 .as_object_mut()
610 .ok_or_else(|| anyhow!("pi models.json has a non-object \"providers\""))?;
611 let mut provider = serde_json::json!({
612 "baseUrl": base_url,
613 "api": endpoint.api.pi_api(),
614 "models": [{"id": endpoint.model}],
615 });
616 if endpoint.api == WireApi::Responses {
617 provider["compat"] = serde_json::json!({"sessionAffinityFormat": "openai-nosession"});
621 }
622 providers.insert(PI_PROVIDER.into(), provider);
623 auth.insert(
624 PI_PROVIDER.into(),
625 serde_json::json!({"type": "api_key", "key": key}),
626 );
627 settings.insert("defaultProvider".into(), PI_PROVIDER.into());
628 settings.insert("defaultModel".into(), endpoint.model.clone().into());
629
630 write_json(&dir.join("models.json"), &models)?;
631 write_json(&dir.join("auth.json"), &auth)?;
632 write_json(&dir.join("settings.json"), &settings)?;
633 Ok(vec![
634 format!(
635 "Configured pi provider {PI_PROVIDER:?}: {} at {}, model {:?}",
636 endpoint.api.pi_api(),
637 host(&base_url),
638 endpoint.model
639 ),
640 "Stored its API key in pi's auth.json (mode 0600) and made it pi's default".into(),
641 ])
642}
643
644fn pi_status(dir: &Path) -> Result<(bool, Vec<String>)> {
645 let settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
646 let auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
647 let models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
648 let mut lines = Vec::new();
649 let text = |object: &serde_json::Map<String, serde_json::Value>, key: &str| {
650 object
651 .get(key)
652 .and_then(serde_json::Value::as_str)
653 .map(ToOwned::to_owned)
654 };
655 let provider = text(&settings, "defaultProvider");
656 if let Some(provider) = &provider {
657 let endpoint = models
658 .get("providers")
659 .and_then(|providers| providers.get(provider))
660 .and_then(serde_json::Value::as_object);
661 let location = endpoint
662 .map(|endpoint| {
663 format!(
664 " ({} at {})",
665 text(endpoint, "api").unwrap_or_else(|| "api unset".into()),
666 text(endpoint, "baseUrl")
667 .map_or_else(|| "no base URL".into(), |url| host(&url))
668 )
669 })
670 .unwrap_or_default();
671 lines.push(format!(
672 "default provider {provider:?}{location}, model {:?}",
673 text(&settings, "defaultModel").unwrap_or_else(|| "unset".into())
674 ));
675 }
676 let mut signed_in: Vec<&String> = auth.keys().collect();
677 signed_in.sort();
678 let ready = !signed_in.is_empty();
679 if ready {
680 let names: Vec<String> = signed_in.iter().map(|name| format!("{name:?}")).collect();
681 lines.push(format!("stored sign-ins: {}", names.join(", ")));
682 } else {
683 lines.push("not signed in".into());
684 }
685 Ok((ready, lines))
686}
687
688fn validate_base_url(value: &str) -> Result<String> {
689 let value = value.trim().trim_end_matches('/');
690 let rest = value
691 .strip_prefix("https://")
692 .or_else(|| value.strip_prefix("http://"))
693 .ok_or_else(|| anyhow!("the base URL must start with https:// or http://"))?;
694 let authority = rest.split('/').next().unwrap_or_default();
695 if authority.is_empty()
696 || authority.contains('@')
697 || value.len() > MAX_FIELD_BYTES
698 || value
699 .chars()
700 .any(|c| c.is_whitespace() || c.is_control() || matches!(c, '?' | '#'))
701 {
702 bail!("the base URL must be a plain http(s) URL without credentials, query, or fragment");
703 }
704 Ok(value.to_owned())
705}
706
707fn host(url: &str) -> String {
709 url.split("://")
710 .nth(1)
711 .and_then(|rest| rest.split('/').next())
712 .unwrap_or(url)
713 .to_owned()
714}
715
716fn valid_model_id(model: &str) -> bool {
717 !model.is_empty()
718 && model.len() <= 128
719 && !model.starts_with(['-', '@'])
720 && model
721 .chars()
722 .all(|c| c.is_ascii_alphanumeric() || "._:/@[]-".contains(c))
723}
724
725fn display(path: &Path, home: &Path) -> String {
726 path.strip_prefix(home).map_or_else(
727 |_| path.display().to_string(),
728 |relative| relative.display().to_string(),
729 )
730}
731
732fn read_json_object(path: &Path) -> Result<Option<serde_json::Map<String, serde_json::Value>>> {
733 let Some(text) = read_bounded(path)? else {
734 return Ok(None);
735 };
736 if text.trim().is_empty() {
737 return Ok(Some(serde_json::Map::new()));
738 }
739 match serde_json::from_str(&text) {
740 Ok(serde_json::Value::Object(object)) => Ok(Some(object)),
741 _ => bail!("{} is not a JSON object", path.display()),
743 }
744}
745
746fn write_json(path: &Path, object: &serde_json::Map<String, serde_json::Value>) -> Result<()> {
747 write_private(
748 path,
749 &format!("{}\n", serde_json::to_string_pretty(object)?),
750 )
751}
752
753fn remove_if_present(path: &Path) -> Result<bool> {
754 match std::fs::remove_file(path) {
755 Ok(()) => Ok(true),
756 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
757 Err(error) => Err(anyhow!(error).context(format!("remove {}", path.display()))),
758 }
759}
760
761fn create_private_dirs(root: &Path, file: &Path) -> Result<()> {
763 let parent = file
764 .parent()
765 .ok_or_else(|| anyhow!("{} has no parent", file.display()))?;
766 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
767 use std::os::unix::fs::PermissionsExt as _;
768 let mut dir = parent;
769 loop {
770 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
771 match dir.parent() {
772 Some(next) if next.starts_with(root) && next != root => dir = next,
773 _ => break,
774 }
775 }
776 Ok(())
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782
783 const CONFIG: &str = r#"model_provider = "relay"
784model = "gpt-test"
785sandbox_mode = "danger-full-access"
786approval_policy = "never"
787
788[model_providers.relay]
789base_url = "https://relay.invalid"
790wire_api = "responses"
791requires_openai_auth = true
792experimental_bearer_token = "sk-bearer-secret"
793"#;
794
795 fn homes() -> (tempfile::TempDir, tempfile::TempDir) {
796 (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap())
797 }
798
799 fn mode(path: &Path) -> u32 {
800 use std::os::unix::fs::PermissionsExt;
801 std::fs::metadata(path).unwrap().permissions().mode() & 0o777
802 }
803
804 #[test]
805 fn copies_config_and_api_key_privately_without_printing_secrets() {
806 let (source, destination) = homes();
807 std::fs::write(source.path().join("config.toml"), CONFIG).unwrap();
808 let auth = r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-auth-secret"}"#;
809 std::fs::write(source.path().join("auth.json"), auth).unwrap();
810 let notes = import_codex(source.path(), destination.path()).unwrap();
811 for file in ["config.toml", "auth.json"] {
812 let copied = destination.path().join(file);
813 assert_eq!(
814 std::fs::read_to_string(&copied).unwrap(),
815 std::fs::read_to_string(source.path().join(file)).unwrap()
816 );
817 assert_eq!(mode(&copied), 0o600);
818 }
819 let notes = notes.join("\n");
820 assert!(notes.contains(r#"model_provider "relay", model "gpt-test""#));
821 assert!(notes.contains(r#"sandbox_mode "danger-full-access" and approval_policy "never""#));
822 assert!(notes.contains("API-key sign-in"));
823 assert!(!notes.contains("secret"));
824 }
825
826 #[test]
827 fn never_copies_a_chatgpt_session() {
828 let (source, destination) = homes();
829 std::fs::write(source.path().join("config.toml"), "model = \"m\"\n").unwrap();
830 std::fs::write(
831 source.path().join("auth.json"),
832 r#"{"auth_mode":"chatgpt","OPENAI_API_KEY":null,"tokens":{"refresh_token":"rt"}}"#,
833 )
834 .unwrap();
835 let notes = import_codex(source.path(), destination.path()).unwrap();
836 assert!(destination.path().join("config.toml").is_file());
837 assert!(!destination.path().join("auth.json").exists());
838 assert!(notes.join("\n").contains("scv agents login codex"));
839 }
840
841 #[test]
842 fn env_key_providers_are_flagged() {
843 let (source, destination) = homes();
844 std::fs::write(
845 source.path().join("config.toml"),
846 "[model_providers.a]\nenv_key = \"OPENAI_API_KEY\"\n\
847 [model_providers.b]\nenv_key = \"RELAY_KEY\"\n",
848 )
849 .unwrap();
850 let notes = import_codex(source.path(), destination.path())
851 .unwrap()
852 .join("\n");
853 assert!(notes.contains("$OPENAI_API_KEY, which SCV removes"));
854 assert!(notes.contains("$RELAY_KEY; the SCV daemon's environment must provide it"));
855 }
856
857 const DSH: KeyStore = KeyStore::DshRefs {
858 path: ".dsh/.credentials.yaml",
859 variable: "DEEPSEEK_API_KEY",
860 };
861 const PI: KeyStore = KeyStore::Pi { dir: ".pi/agent" };
862 const GROK: KeyStore = KeyStore::Grok {
863 auth: ".grok/auth.json",
864 config: ".grok/config.toml",
865 };
866
867 const GROK_CONFIG: &str = r#"[cli]
868installer = "internal"
869
870# The relay profile.
871[model.relay]
872model = "grok-4.5"
873base_url = "https://relay.invalid"
874api_key = "xai-profile-secret"
875api_backend = "responses"
876
877[model."relay-4.7"]
878model = "grok-4.7"
879base_url = "https://relay.invalid"
880api_key = "xai-profile-secret"
881
882[models]
883default = "relay-4.7"
884"#;
885
886 fn grok_status_of(config: &str) -> (bool, String) {
887 let home = tempfile::tempdir().unwrap();
888 std::fs::create_dir(home.path().join(".grok")).unwrap();
889 std::fs::write(home.path().join(".grok/config.toml"), config).unwrap();
890 let (ready, lines) = stored_status(GROK, home.path()).unwrap();
891 (ready, lines.join("\n"))
892 }
893
894 #[test]
895 fn grok_config_keys_count_as_signed_in_without_printing_them() {
896 let (ready, lines) = grok_status_of(GROK_CONFIG);
897 assert!(ready);
898 assert_eq!(lines, r#"signed in (API key in config, model "relay-4.7")"#);
899 let (ready, _) = grok_status_of(
901 &GROK_CONFIG.replace(r#"default = "relay-4.7""#, r#"default = "grok-4.5""#),
902 );
903 assert!(ready);
904 let keyless = "[model.m]\nmodel = \"grok-4.7\"\n[models]\ndefault = \"m\"\n";
906 let (ready, lines) = grok_status_of(keyless);
907 assert!(!ready);
908 assert!(lines.contains(r#"default model "m" has no api_key in config"#));
909 assert!(!grok_status_of("[models]\ndefault = \"grok-9\"\n").0);
910 assert!(!grok_status_of("[cli]\ninstaller = \"internal\"\n").0);
911 let removed = "[model.m]\nenv_key = [\"XAI_API_KEY\"]\n[models]\ndefault = \"m\"\n";
913 let (ready, lines) = grok_status_of(removed);
914 assert!(!ready);
915 assert!(lines.contains("$XAI_API_KEY"), "{lines}");
916 let home = tempfile::tempdir().unwrap();
918 std::fs::create_dir(home.path().join(".grok")).unwrap();
919 std::fs::write(
920 home.path().join(".grok/config.toml"),
921 "api_key = xai-secret",
922 )
923 .unwrap();
924 let error = stored_status(GROK, home.path()).unwrap_err().to_string();
925 assert!(!error.contains("secret"), "{error}");
926 }
927
928 #[test]
929 fn grok_import_merges_by_table_privately_without_printing_keys() {
930 let (source, scv) = homes();
931 let destination = scv.path().join(".grok");
932 std::fs::create_dir(&destination).unwrap();
933 std::fs::write(source.path().join("config.toml"), GROK_CONFIG).unwrap();
934 std::fs::write(
935 source.path().join("auth.json"),
936 r#"{"a":{"key":"xai-token-secret"}}"#,
937 )
938 .unwrap();
939 std::fs::write(
941 destination.join("config.toml"),
942 "[marketplace]\ndefault_skills_installs_purged = true\n\n[cli]\ninstaller = \"old\"\n",
943 )
944 .unwrap();
945 let notes = import_grok(source.path(), &destination).unwrap().join("\n");
946 let copied = destination.join("config.toml");
947 assert_eq!(mode(&copied), 0o600);
948 let text = std::fs::read_to_string(&copied).unwrap();
949 assert!(text.starts_with(GROK_CONFIG), "user formatting is kept");
950 let table: toml::Table = text.parse().unwrap();
951 assert_eq!(table["cli"]["installer"].as_str(), Some("internal"));
952 assert_eq!(
953 table["marketplace"]["default_skills_installs_purged"].as_bool(),
954 Some(true)
955 );
956 assert_eq!(table["models"]["default"].as_str(), Some("relay-4.7"));
957 assert!(!destination.join("auth.json").exists());
958 assert!(notes.contains(r#"default model "relay-4.7"; profiles "relay", "relay-4.7""#));
959 assert!(notes.contains(r#"Kept SCV-only settings: "marketplace""#));
960 assert!(notes.contains("Skipped auth.json"));
961 assert!(!notes.contains("secret"), "{notes}");
962 assert!(stored_status(GROK, scv.path()).unwrap().0);
963 }
964
965 #[test]
966 fn grok_import_writes_nothing_when_either_file_is_invalid() {
967 let (source, scv) = homes();
968 let destination = scv.path().join(".grok");
969 std::fs::create_dir(&destination).unwrap();
970 let existing = "[marketplace]\nkept = true\n";
971 std::fs::write(destination.join("config.toml"), existing).unwrap();
972 std::fs::write(source.path().join("config.toml"), "api_key = xai-secret").unwrap();
973 let error = import_grok(source.path(), &destination)
974 .unwrap_err()
975 .to_string();
976 assert!(!error.contains("secret"), "{error}");
977 assert_eq!(
978 std::fs::read_to_string(destination.join("config.toml")).unwrap(),
979 existing
980 );
981 std::fs::write(source.path().join("config.toml"), GROK_CONFIG).unwrap();
982 std::fs::write(destination.join("config.toml"), "broken = [").unwrap();
983 assert!(import_grok(source.path(), &destination).is_err());
984 assert_eq!(
985 std::fs::read_to_string(destination.join("config.toml")).unwrap(),
986 "broken = ["
987 );
988 assert!(import_grok(&destination, &destination).is_err());
989 let empty = tempfile::tempdir().unwrap();
990 assert!(import_grok(empty.path(), &destination).is_err());
991 }
992
993 #[test]
994 fn grok_import_keeps_top_level_values_outside_the_users_tables() {
995 let (source, scv) = homes();
996 let destination = scv.path().join(".grok");
997 std::fs::create_dir(&destination).unwrap();
998 std::fs::write(source.path().join("config.toml"), GROK_CONFIG).unwrap();
999 std::fs::write(destination.join("config.toml"), "version = 3\n").unwrap();
1000 import_grok(source.path(), &destination).unwrap();
1001 let table: toml::Table = std::fs::read_to_string(destination.join("config.toml"))
1002 .unwrap()
1003 .parse()
1004 .unwrap();
1005 assert_eq!(table["version"].as_integer(), Some(3));
1006 assert!(table["models"].get("version").is_none());
1007 assert_eq!(table["models"]["default"].as_str(), Some("relay-4.7"));
1008 }
1009
1010 fn endpoint() -> Endpoint {
1011 Endpoint {
1012 base_url: "https://relay.invalid/v1/".into(),
1013 api: WireApi::Responses,
1014 model: "gpt-test".into(),
1015 }
1016 }
1017
1018 #[test]
1019 fn dsh_keys_are_stored_privately_in_its_native_file() {
1020 let home = tempfile::tempdir().unwrap();
1021 assert!(!stored_status(DSH, home.path()).unwrap().0);
1022 let notes = store_key(DSH, home.path(), "sk-dsh-secret").unwrap();
1023 assert!(!notes.join("\n").contains("secret"));
1024 let path = home.path().join(".dsh/.credentials.yaml");
1025 assert_eq!(
1026 std::fs::read_to_string(&path).unwrap(),
1027 "version: 1\n\nrefs:\n DEEPSEEK_API_KEY: \"sk-dsh-secret\"\n"
1028 );
1029 assert_eq!(mode(&path), 0o600);
1030 assert_eq!(mode(&home.path().join(".dsh")), 0o700);
1031 let (ready, lines) = stored_status(DSH, home.path()).unwrap();
1032 assert!(ready);
1033 assert!(!lines.join("\n").contains("secret"));
1034 for invalid in ["", "two words", "quote\"d", "line\nbreak"] {
1035 assert!(store_key(DSH, home.path(), invalid).is_err(), "{invalid:?}");
1036 }
1037 assert_eq!(
1038 remove_stored(DSH, home.path()).unwrap(),
1039 ["Removed .dsh/.credentials.yaml"]
1040 );
1041 assert!(!path.exists());
1042 assert!(!stored_status(DSH, home.path()).unwrap().0);
1043 }
1044
1045 #[test]
1046 fn grok_login_entries_report_sign_in_without_values() {
1047 let home = tempfile::tempdir().unwrap();
1048 let store = GROK;
1049 assert!(!stored_status(store, home.path()).unwrap().0);
1050 std::fs::create_dir(home.path().join(".grok")).unwrap();
1051 std::fs::write(home.path().join(".grok/auth.json"), "{}").unwrap();
1052 assert!(!stored_status(store, home.path()).unwrap().0);
1053 std::fs::write(
1054 home.path().join(".grok/auth.json"),
1055 r#"{"https://auth.x.ai::id":{"key":"xai-token-secret"}}"#,
1056 )
1057 .unwrap();
1058 let (ready, lines) = stored_status(store, home.path()).unwrap();
1059 assert!(ready);
1060 assert!(!lines.join("\n").contains("secret"));
1061 std::fs::write(home.path().join(".grok/auth.json"), "xai-token-secret").unwrap();
1062 let error = stored_status(store, home.path()).unwrap_err().to_string();
1063 assert!(!error.contains("secret"), "{error}");
1064 }
1065
1066 #[test]
1067 fn pi_endpoints_merge_into_pi_files_as_the_private_default() {
1068 let home = tempfile::tempdir().unwrap();
1069 let dir = home.path().join(".pi/agent");
1070 std::fs::create_dir_all(&dir).unwrap();
1071 std::fs::write(
1072 dir.join("models.json"),
1073 r#"{"providers":{"ollama":{"baseUrl":"http://localhost:11434/v1","api":"openai-completions","models":[{"id":"q"}]}}}"#,
1074 )
1075 .unwrap();
1076 std::fs::write(dir.join("settings.json"), r#"{"theme":"dark"}"#).unwrap();
1077 let notes = configure_pi_endpoint(&dir, &endpoint(), "sk-pi-secret").unwrap();
1078 let notes = notes.join("\n");
1079 assert!(
1080 notes.contains("openai-responses at relay.invalid"),
1081 "{notes}"
1082 );
1083 assert!(!notes.contains("secret"));
1084
1085 let read = |file: &str| -> serde_json::Value {
1086 let path = dir.join(file);
1087 assert_eq!(mode(&path), 0o600, "{file}");
1088 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
1089 };
1090 let models = read("models.json");
1091 assert_eq!(
1092 models["providers"]["scv"],
1093 serde_json::json!({
1094 "baseUrl": "https://relay.invalid/v1",
1095 "api": "openai-responses",
1096 "models": [{"id": "gpt-test"}],
1097 "compat": {"sessionAffinityFormat": "openai-nosession"},
1098 })
1099 );
1100 assert_eq!(models["providers"]["ollama"]["models"][0]["id"], "q");
1101 assert_eq!(
1102 read("auth.json")["scv"],
1103 serde_json::json!({"type": "api_key", "key": "sk-pi-secret"})
1104 );
1105 let settings = read("settings.json");
1106 assert_eq!(settings["defaultProvider"], "scv");
1107 assert_eq!(settings["defaultModel"], "gpt-test");
1108 assert_eq!(settings["theme"], "dark");
1109
1110 let (ready, lines) = stored_status(PI, home.path()).unwrap();
1111 let lines = lines.join("\n");
1112 assert!(ready);
1113 assert!(
1114 lines.contains(
1115 r#"default provider "scv" (openai-responses at relay.invalid), model "gpt-test""#
1116 ),
1117 "{lines}"
1118 );
1119 assert!(!lines.contains("secret"));
1120
1121 let removed = remove_stored(PI, home.path()).unwrap().join("\n");
1122 assert!(removed.contains("auth.json"), "{removed}");
1123 assert!(!dir.join("auth.json").exists());
1124 let models = read("models.json");
1125 assert!(models["providers"].get("scv").is_none());
1126 assert!(models["providers"].get("ollama").is_some());
1127 let settings = read("settings.json");
1128 assert!(settings.get("defaultProvider").is_none());
1129 assert_eq!(settings["theme"], "dark");
1130 assert!(!stored_status(PI, home.path()).unwrap().0);
1131 }
1132
1133 #[test]
1134 fn pi_endpoint_input_is_validated_before_any_write() {
1135 let home = tempfile::tempdir().unwrap();
1136 let dir = home.path().join(".pi/agent");
1137 for base_url in [
1138 "ftp://relay.invalid",
1139 "https://user:pass@relay.invalid",
1140 "https://relay.invalid/v1?key=x",
1141 "https://",
1142 ] {
1143 let endpoint = Endpoint {
1144 base_url: base_url.into(),
1145 ..endpoint()
1146 };
1147 assert!(
1148 configure_pi_endpoint(&dir, &endpoint, "sk").is_err(),
1149 "{base_url}"
1150 );
1151 }
1152 let endpoint = Endpoint {
1153 model: "--flag".into(),
1154 ..endpoint()
1155 };
1156 assert!(configure_pi_endpoint(&dir, &endpoint, "sk").is_err());
1157 assert!(configure_pi_endpoint(&dir, &super::tests::endpoint(), "").is_err());
1158 assert!(!dir.exists());
1159
1160 std::fs::create_dir_all(&dir).unwrap();
1161 std::fs::write(dir.join("models.json"), "not json").unwrap();
1162 assert!(configure_pi_endpoint(&dir, &super::tests::endpoint(), "sk").is_err());
1163 assert!(!dir.join("auth.json").exists());
1164 assert!(!dir.join("settings.json").exists());
1165 }
1166
1167 #[test]
1168 fn invalid_input_writes_nothing() {
1169 let (source, destination) = homes();
1170 std::fs::write(source.path().join("config.toml"), "model = \"m\"\n").unwrap();
1171 std::fs::write(source.path().join("auth.json"), "not json").unwrap();
1172 assert!(import_codex(source.path(), destination.path()).is_err());
1173 std::fs::write(source.path().join("config.toml"), "model = ").unwrap();
1174 std::fs::remove_file(source.path().join("auth.json")).unwrap();
1175 assert!(import_codex(source.path(), destination.path()).is_err());
1176 assert_eq!(std::fs::read_dir(destination.path()).unwrap().count(), 0);
1177
1178 let empty = tempfile::tempdir().unwrap();
1179 assert!(import_codex(empty.path(), destination.path()).is_err());
1180 assert!(import_codex(destination.path(), destination.path()).is_err());
1181 }
1182}