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
156fn write_private(path: &Path, contents: &str) -> Result<()> {
157 let parent = path
158 .parent()
159 .ok_or_else(|| anyhow!("import path has no parent"))?;
160 let mut temporary =
162 tempfile::NamedTempFile::new_in(parent).context("create temporary import file")?;
163 temporary
164 .write_all(contents.as_bytes())
165 .context("write imported file")?;
166 temporary.as_file().sync_all()?;
167 temporary
168 .persist(path)
169 .map_err(|error| anyhow!("replace {}: {}", path.display(), error.error))?;
170 Ok(())
171}
172
173const MAX_FIELD_BYTES: usize = 4096;
175
176pub const PI_PROVIDER: &str = "scv";
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum WireApi {
182 Responses,
183 ChatCompletions,
184}
185
186impl WireApi {
187 fn pi_api(self) -> &'static str {
188 match self {
189 Self::Responses => "openai-responses",
190 Self::ChatCompletions => "openai-completions",
191 }
192 }
193}
194
195#[derive(Debug, Clone)]
197pub struct Endpoint {
198 pub base_url: String,
199 pub api: WireApi,
200 pub model: String,
201}
202
203pub fn read_secret(prompt: &str) -> Result<String> {
205 use std::io::{BufRead as _, IsTerminal as _};
206 let stdin = std::io::stdin();
207 let mut line = String::new();
208 if stdin.is_terminal() {
209 eprint!("{prompt}: ");
210 std::io::stderr().flush().ok();
211 let _echo = EchoOff::new()?;
212 stdin.lock().read_line(&mut line)?;
213 eprintln!();
214 } else {
215 stdin
216 .lock()
217 .take(MAX_FIELD_BYTES as u64 + 2)
218 .read_line(&mut line)?;
219 }
220 let secret = line.trim().to_owned();
221 validate_secret(&secret)?;
222 Ok(secret)
223}
224
225struct EchoOff(libc::termios);
227
228impl EchoOff {
229 fn new() -> Result<Self> {
230 let mut termios = std::mem::MaybeUninit::<libc::termios>::uninit();
231 if unsafe { libc::tcgetattr(libc::STDIN_FILENO, termios.as_mut_ptr()) } != 0 {
233 bail!(
234 "read terminal settings: {}",
235 std::io::Error::last_os_error()
236 );
237 }
238 let original = unsafe { termios.assume_init() };
240 let mut silent = original;
241 silent.c_lflag &= !libc::ECHO;
242 if unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &silent) } != 0 {
244 bail!("disable terminal echo: {}", std::io::Error::last_os_error());
245 }
246 Ok(Self(original))
247 }
248}
249
250impl Drop for EchoOff {
251 fn drop(&mut self) {
252 unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &self.0) };
254 }
255}
256
257fn validate_secret(secret: &str) -> Result<()> {
258 if secret.is_empty() {
259 bail!("no key entered");
260 }
261 if secret.len() > MAX_FIELD_BYTES
262 || secret
263 .chars()
264 .any(|c| c.is_whitespace() || c.is_control() || c == '"' || c == '\\')
265 {
266 bail!("the key must be one line of at most {MAX_FIELD_BYTES} printable characters");
267 }
268 Ok(())
269}
270
271pub fn store_key(store: KeyStore, home: &Path, key: &str) -> Result<Vec<String>> {
273 validate_secret(key)?;
274 match store {
275 KeyStore::DshRefs { path, variable } => {
276 let path = home.join(path);
277 create_private_dirs(home, &path)?;
278 let quoted = serde_json::to_string(key)?;
280 write_private(
281 &path,
282 &format!("version: 1\n\nrefs:\n {variable}: {quoted}\n"),
283 )?;
284 Ok(vec![format!(
285 "Stored the API key as {variable} in {}",
286 display(&path, home)
287 )])
288 }
289 KeyStore::JsonEntries(_) | KeyStore::Pi { .. } => {
290 bail!("this agent signs in with its own login, not a stored key")
291 }
292 }
293}
294
295pub fn stored_status(store: KeyStore, home: &Path) -> Result<(bool, Vec<String>)> {
297 match store {
298 KeyStore::JsonEntries(path) => {
299 let path = home.join(path);
300 let entries = read_json_object(&path)?
301 .map(|object| object.values().filter(|value| !value.is_null()).count())
302 .unwrap_or(0);
303 Ok(if entries > 0 {
304 (true, vec![format!("signed in ({})", display(&path, home))])
305 } else {
306 (false, vec!["not signed in".into()])
307 })
308 }
309 KeyStore::DshRefs { path, variable } => {
310 let path = home.join(path);
311 let stored = read_bounded(&path)?.is_some_and(|text| {
312 text.lines().any(|line| {
313 line.trim_start()
314 .strip_prefix(variable)
315 .and_then(|rest| rest.strip_prefix(':'))
316 .is_some_and(|value| !matches!(value.trim(), "" | "\"\"" | "''"))
317 })
318 });
319 Ok(if stored {
320 (true, vec![format!("API key stored as {variable}")])
321 } else {
322 (false, vec!["not signed in".into()])
323 })
324 }
325 KeyStore::Pi { dir } => pi_status(&home.join(dir)),
326 }
327}
328
329pub fn remove_stored(store: KeyStore, home: &Path) -> Result<Vec<String>> {
331 match store {
332 KeyStore::JsonEntries(path) | KeyStore::DshRefs { path, .. } => {
333 let path = home.join(path);
334 Ok(vec![if remove_if_present(&path)? {
335 format!("Removed {}", display(&path, home))
336 } else {
337 "Nothing stored".into()
338 }])
339 }
340 KeyStore::Pi { dir } => {
341 let dir = home.join(dir);
342 let mut notes = Vec::new();
343 if remove_if_present(&dir.join("auth.json"))? {
344 notes.push("Removed pi's stored sign-ins (auth.json)".into());
345 }
346 let models = dir.join("models.json");
347 if let Some(mut object) = read_json_object(&models)?
348 && let Some(providers) = object
349 .get_mut("providers")
350 .and_then(serde_json::Value::as_object_mut)
351 && providers.remove(PI_PROVIDER).is_some()
352 {
353 write_json(&models, &object)?;
354 notes.push(format!(
355 "Removed the {PI_PROVIDER} endpoint from models.json"
356 ));
357 }
358 let settings = dir.join("settings.json");
359 if let Some(mut object) = read_json_object(&settings)?
360 && object
361 .get("defaultProvider")
362 .and_then(serde_json::Value::as_str)
363 == Some(PI_PROVIDER)
364 {
365 object.remove("defaultProvider");
366 object.remove("defaultModel");
367 write_json(&settings, &object)?;
368 notes.push("Cleared pi's default model".into());
369 }
370 if notes.is_empty() {
371 notes.push("Nothing stored".into());
372 }
373 Ok(notes)
374 }
375 }
376}
377
378pub fn configure_pi_endpoint(dir: &Path, endpoint: &Endpoint, key: &str) -> Result<Vec<String>> {
381 validate_secret(key)?;
382 let base_url = validate_base_url(&endpoint.base_url)?;
383 if !valid_model_id(&endpoint.model) {
384 bail!("invalid model id {:?}", endpoint.model);
385 }
386 create_private_dirs(dir, &dir.join("auth.json"))?;
387 let mut models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
389 let mut auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
390 let mut settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
391
392 let providers = models
393 .entry("providers")
394 .or_insert_with(|| serde_json::json!({}));
395 let providers = providers
396 .as_object_mut()
397 .ok_or_else(|| anyhow!("pi models.json has a non-object \"providers\""))?;
398 let mut provider = serde_json::json!({
399 "baseUrl": base_url,
400 "api": endpoint.api.pi_api(),
401 "models": [{"id": endpoint.model}],
402 });
403 if endpoint.api == WireApi::Responses {
404 provider["compat"] = serde_json::json!({"sessionAffinityFormat": "openai-nosession"});
408 }
409 providers.insert(PI_PROVIDER.into(), provider);
410 auth.insert(
411 PI_PROVIDER.into(),
412 serde_json::json!({"type": "api_key", "key": key}),
413 );
414 settings.insert("defaultProvider".into(), PI_PROVIDER.into());
415 settings.insert("defaultModel".into(), endpoint.model.clone().into());
416
417 write_json(&dir.join("models.json"), &models)?;
418 write_json(&dir.join("auth.json"), &auth)?;
419 write_json(&dir.join("settings.json"), &settings)?;
420 Ok(vec![
421 format!(
422 "Configured pi provider {PI_PROVIDER:?}: {} at {}, model {:?}",
423 endpoint.api.pi_api(),
424 host(&base_url),
425 endpoint.model
426 ),
427 "Stored its API key in pi's auth.json (mode 0600) and made it pi's default".into(),
428 ])
429}
430
431fn pi_status(dir: &Path) -> Result<(bool, Vec<String>)> {
432 let settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
433 let auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
434 let models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
435 let mut lines = Vec::new();
436 let text = |object: &serde_json::Map<String, serde_json::Value>, key: &str| {
437 object
438 .get(key)
439 .and_then(serde_json::Value::as_str)
440 .map(ToOwned::to_owned)
441 };
442 let provider = text(&settings, "defaultProvider");
443 if let Some(provider) = &provider {
444 let endpoint = models
445 .get("providers")
446 .and_then(|providers| providers.get(provider))
447 .and_then(serde_json::Value::as_object);
448 let location = endpoint
449 .map(|endpoint| {
450 format!(
451 " ({} at {})",
452 text(endpoint, "api").unwrap_or_else(|| "api unset".into()),
453 text(endpoint, "baseUrl")
454 .map_or_else(|| "no base URL".into(), |url| host(&url))
455 )
456 })
457 .unwrap_or_default();
458 lines.push(format!(
459 "default provider {provider:?}{location}, model {:?}",
460 text(&settings, "defaultModel").unwrap_or_else(|| "unset".into())
461 ));
462 }
463 let mut signed_in: Vec<&String> = auth.keys().collect();
464 signed_in.sort();
465 let ready = !signed_in.is_empty();
466 if ready {
467 let names: Vec<String> = signed_in.iter().map(|name| format!("{name:?}")).collect();
468 lines.push(format!("stored sign-ins: {}", names.join(", ")));
469 } else {
470 lines.push("not signed in".into());
471 }
472 Ok((ready, lines))
473}
474
475fn validate_base_url(value: &str) -> Result<String> {
476 let value = value.trim().trim_end_matches('/');
477 let rest = value
478 .strip_prefix("https://")
479 .or_else(|| value.strip_prefix("http://"))
480 .ok_or_else(|| anyhow!("the base URL must start with https:// or http://"))?;
481 let authority = rest.split('/').next().unwrap_or_default();
482 if authority.is_empty()
483 || authority.contains('@')
484 || value.len() > MAX_FIELD_BYTES
485 || value
486 .chars()
487 .any(|c| c.is_whitespace() || c.is_control() || matches!(c, '?' | '#'))
488 {
489 bail!("the base URL must be a plain http(s) URL without credentials, query, or fragment");
490 }
491 Ok(value.to_owned())
492}
493
494fn host(url: &str) -> String {
496 url.split("://")
497 .nth(1)
498 .and_then(|rest| rest.split('/').next())
499 .unwrap_or(url)
500 .to_owned()
501}
502
503fn valid_model_id(model: &str) -> bool {
504 !model.is_empty()
505 && model.len() <= 128
506 && !model.starts_with(['-', '@'])
507 && model
508 .chars()
509 .all(|c| c.is_ascii_alphanumeric() || "._:/@[]-".contains(c))
510}
511
512fn display(path: &Path, home: &Path) -> String {
513 path.strip_prefix(home).map_or_else(
514 |_| path.display().to_string(),
515 |relative| relative.display().to_string(),
516 )
517}
518
519fn read_json_object(path: &Path) -> Result<Option<serde_json::Map<String, serde_json::Value>>> {
520 let Some(text) = read_bounded(path)? else {
521 return Ok(None);
522 };
523 if text.trim().is_empty() {
524 return Ok(Some(serde_json::Map::new()));
525 }
526 match serde_json::from_str(&text) {
527 Ok(serde_json::Value::Object(object)) => Ok(Some(object)),
528 _ => bail!("{} is not a JSON object", path.display()),
530 }
531}
532
533fn write_json(path: &Path, object: &serde_json::Map<String, serde_json::Value>) -> Result<()> {
534 write_private(
535 path,
536 &format!("{}\n", serde_json::to_string_pretty(object)?),
537 )
538}
539
540fn remove_if_present(path: &Path) -> Result<bool> {
541 match std::fs::remove_file(path) {
542 Ok(()) => Ok(true),
543 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
544 Err(error) => Err(anyhow!(error).context(format!("remove {}", path.display()))),
545 }
546}
547
548fn create_private_dirs(root: &Path, file: &Path) -> Result<()> {
550 let parent = file
551 .parent()
552 .ok_or_else(|| anyhow!("{} has no parent", file.display()))?;
553 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
554 use std::os::unix::fs::PermissionsExt as _;
555 let mut dir = parent;
556 loop {
557 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
558 match dir.parent() {
559 Some(next) if next.starts_with(root) && next != root => dir = next,
560 _ => break,
561 }
562 }
563 Ok(())
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 const CONFIG: &str = r#"model_provider = "relay"
571model = "gpt-test"
572sandbox_mode = "danger-full-access"
573approval_policy = "never"
574
575[model_providers.relay]
576base_url = "https://relay.invalid"
577wire_api = "responses"
578requires_openai_auth = true
579experimental_bearer_token = "sk-bearer-secret"
580"#;
581
582 fn homes() -> (tempfile::TempDir, tempfile::TempDir) {
583 (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap())
584 }
585
586 fn mode(path: &Path) -> u32 {
587 use std::os::unix::fs::PermissionsExt;
588 std::fs::metadata(path).unwrap().permissions().mode() & 0o777
589 }
590
591 #[test]
592 fn copies_config_and_api_key_privately_without_printing_secrets() {
593 let (source, destination) = homes();
594 std::fs::write(source.path().join("config.toml"), CONFIG).unwrap();
595 let auth = r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-auth-secret"}"#;
596 std::fs::write(source.path().join("auth.json"), auth).unwrap();
597 let notes = import_codex(source.path(), destination.path()).unwrap();
598 for file in ["config.toml", "auth.json"] {
599 let copied = destination.path().join(file);
600 assert_eq!(
601 std::fs::read_to_string(&copied).unwrap(),
602 std::fs::read_to_string(source.path().join(file)).unwrap()
603 );
604 assert_eq!(mode(&copied), 0o600);
605 }
606 let notes = notes.join("\n");
607 assert!(notes.contains(r#"model_provider "relay", model "gpt-test""#));
608 assert!(notes.contains(r#"sandbox_mode "danger-full-access" and approval_policy "never""#));
609 assert!(notes.contains("API-key sign-in"));
610 assert!(!notes.contains("secret"));
611 }
612
613 #[test]
614 fn never_copies_a_chatgpt_session() {
615 let (source, destination) = homes();
616 std::fs::write(source.path().join("config.toml"), "model = \"m\"\n").unwrap();
617 std::fs::write(
618 source.path().join("auth.json"),
619 r#"{"auth_mode":"chatgpt","OPENAI_API_KEY":null,"tokens":{"refresh_token":"rt"}}"#,
620 )
621 .unwrap();
622 let notes = import_codex(source.path(), destination.path()).unwrap();
623 assert!(destination.path().join("config.toml").is_file());
624 assert!(!destination.path().join("auth.json").exists());
625 assert!(notes.join("\n").contains("scv agents login codex"));
626 }
627
628 #[test]
629 fn env_key_providers_are_flagged() {
630 let (source, destination) = homes();
631 std::fs::write(
632 source.path().join("config.toml"),
633 "[model_providers.a]\nenv_key = \"OPENAI_API_KEY\"\n\
634 [model_providers.b]\nenv_key = \"RELAY_KEY\"\n",
635 )
636 .unwrap();
637 let notes = import_codex(source.path(), destination.path())
638 .unwrap()
639 .join("\n");
640 assert!(notes.contains("$OPENAI_API_KEY, which SCV removes"));
641 assert!(notes.contains("$RELAY_KEY; the SCV daemon's environment must provide it"));
642 }
643
644 const DSH: KeyStore = KeyStore::DshRefs {
645 path: ".dsh/.credentials.yaml",
646 variable: "DEEPSEEK_API_KEY",
647 };
648 const PI: KeyStore = KeyStore::Pi { dir: ".pi/agent" };
649
650 fn endpoint() -> Endpoint {
651 Endpoint {
652 base_url: "https://relay.invalid/v1/".into(),
653 api: WireApi::Responses,
654 model: "gpt-test".into(),
655 }
656 }
657
658 #[test]
659 fn dsh_keys_are_stored_privately_in_its_native_file() {
660 let home = tempfile::tempdir().unwrap();
661 assert!(!stored_status(DSH, home.path()).unwrap().0);
662 let notes = store_key(DSH, home.path(), "sk-dsh-secret").unwrap();
663 assert!(!notes.join("\n").contains("secret"));
664 let path = home.path().join(".dsh/.credentials.yaml");
665 assert_eq!(
666 std::fs::read_to_string(&path).unwrap(),
667 "version: 1\n\nrefs:\n DEEPSEEK_API_KEY: \"sk-dsh-secret\"\n"
668 );
669 assert_eq!(mode(&path), 0o600);
670 assert_eq!(mode(&home.path().join(".dsh")), 0o700);
671 let (ready, lines) = stored_status(DSH, home.path()).unwrap();
672 assert!(ready);
673 assert!(!lines.join("\n").contains("secret"));
674 for invalid in ["", "two words", "quote\"d", "line\nbreak"] {
675 assert!(store_key(DSH, home.path(), invalid).is_err(), "{invalid:?}");
676 }
677 assert_eq!(
678 remove_stored(DSH, home.path()).unwrap(),
679 ["Removed .dsh/.credentials.yaml"]
680 );
681 assert!(!path.exists());
682 assert!(!stored_status(DSH, home.path()).unwrap().0);
683 }
684
685 #[test]
686 fn json_entry_stores_report_sign_in_without_values() {
687 let home = tempfile::tempdir().unwrap();
688 let store = KeyStore::JsonEntries(".grok/auth.json");
689 assert!(!stored_status(store, home.path()).unwrap().0);
690 std::fs::create_dir(home.path().join(".grok")).unwrap();
691 std::fs::write(home.path().join(".grok/auth.json"), "{}").unwrap();
692 assert!(!stored_status(store, home.path()).unwrap().0);
693 std::fs::write(
694 home.path().join(".grok/auth.json"),
695 r#"{"https://auth.x.ai::id":{"key":"xai-token-secret"}}"#,
696 )
697 .unwrap();
698 let (ready, lines) = stored_status(store, home.path()).unwrap();
699 assert!(ready);
700 assert!(!lines.join("\n").contains("secret"));
701 std::fs::write(home.path().join(".grok/auth.json"), "xai-token-secret").unwrap();
702 let error = stored_status(store, home.path()).unwrap_err().to_string();
703 assert!(!error.contains("secret"), "{error}");
704 }
705
706 #[test]
707 fn pi_endpoints_merge_into_pi_files_as_the_private_default() {
708 let home = tempfile::tempdir().unwrap();
709 let dir = home.path().join(".pi/agent");
710 std::fs::create_dir_all(&dir).unwrap();
711 std::fs::write(
712 dir.join("models.json"),
713 r#"{"providers":{"ollama":{"baseUrl":"http://localhost:11434/v1","api":"openai-completions","models":[{"id":"q"}]}}}"#,
714 )
715 .unwrap();
716 std::fs::write(dir.join("settings.json"), r#"{"theme":"dark"}"#).unwrap();
717 let notes = configure_pi_endpoint(&dir, &endpoint(), "sk-pi-secret").unwrap();
718 let notes = notes.join("\n");
719 assert!(
720 notes.contains("openai-responses at relay.invalid"),
721 "{notes}"
722 );
723 assert!(!notes.contains("secret"));
724
725 let read = |file: &str| -> serde_json::Value {
726 let path = dir.join(file);
727 assert_eq!(mode(&path), 0o600, "{file}");
728 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
729 };
730 let models = read("models.json");
731 assert_eq!(
732 models["providers"]["scv"],
733 serde_json::json!({
734 "baseUrl": "https://relay.invalid/v1",
735 "api": "openai-responses",
736 "models": [{"id": "gpt-test"}],
737 "compat": {"sessionAffinityFormat": "openai-nosession"},
738 })
739 );
740 assert_eq!(models["providers"]["ollama"]["models"][0]["id"], "q");
741 assert_eq!(
742 read("auth.json")["scv"],
743 serde_json::json!({"type": "api_key", "key": "sk-pi-secret"})
744 );
745 let settings = read("settings.json");
746 assert_eq!(settings["defaultProvider"], "scv");
747 assert_eq!(settings["defaultModel"], "gpt-test");
748 assert_eq!(settings["theme"], "dark");
749
750 let (ready, lines) = stored_status(PI, home.path()).unwrap();
751 let lines = lines.join("\n");
752 assert!(ready);
753 assert!(
754 lines.contains(
755 r#"default provider "scv" (openai-responses at relay.invalid), model "gpt-test""#
756 ),
757 "{lines}"
758 );
759 assert!(!lines.contains("secret"));
760
761 let removed = remove_stored(PI, home.path()).unwrap().join("\n");
762 assert!(removed.contains("auth.json"), "{removed}");
763 assert!(!dir.join("auth.json").exists());
764 let models = read("models.json");
765 assert!(models["providers"].get("scv").is_none());
766 assert!(models["providers"].get("ollama").is_some());
767 let settings = read("settings.json");
768 assert!(settings.get("defaultProvider").is_none());
769 assert_eq!(settings["theme"], "dark");
770 assert!(!stored_status(PI, home.path()).unwrap().0);
771 }
772
773 #[test]
774 fn pi_endpoint_input_is_validated_before_any_write() {
775 let home = tempfile::tempdir().unwrap();
776 let dir = home.path().join(".pi/agent");
777 for base_url in [
778 "ftp://relay.invalid",
779 "https://user:pass@relay.invalid",
780 "https://relay.invalid/v1?key=x",
781 "https://",
782 ] {
783 let endpoint = Endpoint {
784 base_url: base_url.into(),
785 ..endpoint()
786 };
787 assert!(
788 configure_pi_endpoint(&dir, &endpoint, "sk").is_err(),
789 "{base_url}"
790 );
791 }
792 let endpoint = Endpoint {
793 model: "--flag".into(),
794 ..endpoint()
795 };
796 assert!(configure_pi_endpoint(&dir, &endpoint, "sk").is_err());
797 assert!(configure_pi_endpoint(&dir, &super::tests::endpoint(), "").is_err());
798 assert!(!dir.exists());
799
800 std::fs::create_dir_all(&dir).unwrap();
801 std::fs::write(dir.join("models.json"), "not json").unwrap();
802 assert!(configure_pi_endpoint(&dir, &super::tests::endpoint(), "sk").is_err());
803 assert!(!dir.join("auth.json").exists());
804 assert!(!dir.join("settings.json").exists());
805 }
806
807 #[test]
808 fn invalid_input_writes_nothing() {
809 let (source, destination) = homes();
810 std::fs::write(source.path().join("config.toml"), "model = \"m\"\n").unwrap();
811 std::fs::write(source.path().join("auth.json"), "not json").unwrap();
812 assert!(import_codex(source.path(), destination.path()).is_err());
813 std::fs::write(source.path().join("config.toml"), "model = ").unwrap();
814 std::fs::remove_file(source.path().join("auth.json")).unwrap();
815 assert!(import_codex(source.path(), destination.path()).is_err());
816 assert_eq!(std::fs::read_dir(destination.path()).unwrap().count(), 0);
817
818 let empty = tempfile::tempdir().unwrap();
819 assert!(import_codex(empty.path(), destination.path()).is_err());
820 assert!(import_codex(destination.path(), destination.path()).is_err());
821 }
822}