1use std::io::Read as _;
6#[cfg(unix)]
7use std::os::unix::fs::PermissionsExt;
8use std::path::Path;
9
10use anyhow::{Context, Result, anyhow, bail};
11
12use super::adapters::{AgentAuth, KeyStore};
13
14const MAX_IMPORT_BYTES: u64 = 1024 * 1024;
15
16enum Auth {
17 ApiKey,
19 Session,
21 None,
22}
23
24pub fn import_codex(source: &Path, destination: &Path) -> Result<Vec<String>> {
28 let source = std::fs::canonicalize(source)
29 .with_context(|| format!("resolve Codex home {}", source.display()))?;
30 let destination = std::fs::canonicalize(destination)
31 .with_context(|| format!("resolve SCV Codex home {}", destination.display()))?;
32 if source == destination {
33 bail!(
34 "{} is already SCV's Codex agent home; pass your own Codex home with --from",
35 source.display()
36 );
37 }
38 let config = read_bounded(&source.join("config.toml"))?;
39 let auth = read_bounded(&source.join("auth.json"))?;
40 if config.is_none() && auth.is_none() {
41 bail!("no config.toml or auth.json in {}", source.display());
42 }
43 let table = config
44 .as_deref()
45 .map(str::parse::<toml::Table>)
46 .transpose()
47 .context("parse Codex config.toml")?;
48 let auth_kind = auth.as_deref().map(classify_auth).transpose()?;
49
50 let mut notes = Vec::new();
51 if let (Some(text), Some(table)) = (&config, &table) {
52 write_private(&destination.join("config.toml"), text)?;
53 notes.push(format!("Copied config.toml{}", describe(table)));
54 notes.extend(config_notes(table));
55 }
56 match (&auth, auth_kind) {
57 (Some(text), Some(Auth::ApiKey)) => {
58 write_private(&destination.join("auth.json"), text)?;
59 notes.push("Copied the API-key sign-in from auth.json".into());
60 }
61 (Some(_), Some(Auth::Session)) => notes.push(
62 "Skipped auth.json: a ChatGPT sign-in's refresh token must not be shared; \
63 sign SCV in separately with `scv agents login codex`"
64 .into(),
65 ),
66 (Some(_), Some(Auth::None)) => notes.push("Skipped auth.json: it holds no API key".into()),
67 _ => {}
68 }
69 Ok(notes)
70}
71
72pub fn codex_copied_files(source: &Path) -> Vec<String> {
75 let mut files = Vec::new();
76 if source.join("config.toml").is_file() {
77 files.push("config.toml".to_owned());
78 }
79 if let Ok(Some(text)) = read_bounded(&source.join("auth.json"))
80 && matches!(classify_auth(&text), Ok(Auth::ApiKey))
81 {
82 files.push("auth.json".to_owned());
83 }
84 files
85}
86
87fn read_bounded(path: &Path) -> Result<Option<String>> {
88 let file = match std::fs::File::open(path) {
89 Ok(file) => file,
90 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
91 Err(error) => return Err(anyhow!(error).context(format!("open {}", path.display()))),
92 };
93 if !file.metadata()?.is_file() {
94 bail!("{} is not a regular file", path.display());
95 }
96 let mut text = String::new();
97 file.take(MAX_IMPORT_BYTES + 1)
98 .read_to_string(&mut text)
99 .with_context(|| format!("read {}", path.display()))?;
100 if text.len() as u64 > MAX_IMPORT_BYTES {
101 bail!("{} exceeds 1 MiB", path.display());
102 }
103 Ok(Some(text))
104}
105
106fn classify_auth(text: &str) -> Result<Auth> {
107 let value: serde_json::Value =
108 serde_json::from_str(text).map_err(|_| anyhow!("Codex auth.json is not valid JSON"))?;
109 let object = value
110 .as_object()
111 .ok_or_else(|| anyhow!("Codex auth.json is not a JSON object"))?;
112 if object.get("tokens").is_some_and(|tokens| !tokens.is_null()) {
113 return Ok(Auth::Session);
114 }
115 let has_key = object
116 .get("OPENAI_API_KEY")
117 .and_then(serde_json::Value::as_str)
118 .is_some_and(|key| !key.trim().is_empty());
119 Ok(if has_key { Auth::ApiKey } else { Auth::None })
120}
121
122fn describe(table: &toml::Table) -> String {
124 let fields: Vec<String> = ["model_provider", "model"]
125 .into_iter()
126 .filter_map(|key| Some(format!("{key} {:?}", table.get(key)?.as_str()?)))
127 .collect();
128 if fields.is_empty() {
129 String::new()
130 } else {
131 format!(" ({})", fields.join(", "))
132 }
133}
134
135fn config_notes(table: &toml::Table) -> Vec<String> {
136 let mut notes = Vec::new();
137 let policy: Vec<String> = ["sandbox_mode", "approval_policy"]
138 .into_iter()
139 .filter_map(|key| Some(format!("{key} {:?}", table.get(key)?.as_str()?)))
140 .collect();
141 if !policy.is_empty() {
142 notes.push(format!(
143 "Delegated Codex runs also use {}",
144 policy.join(" and ")
145 ));
146 }
147 let providers = table
148 .get("model_providers")
149 .and_then(toml::Value::as_table)
150 .into_iter()
151 .flatten();
152 for (name, provider) in providers {
153 let Some(variable) = provider.get("env_key").and_then(toml::Value::as_str) else {
154 continue;
155 };
156 notes.push(
157 if super::adapters::is_removed_agent_variable(std::ffi::OsStr::new(variable)) {
158 format!(
159 "Warning: provider {name:?} reads its key from ${variable}, which SCV \
160 removes from delegated agents; keep the key in auth.json \
161 (requires_openai_auth) or experimental_bearer_token instead"
162 )
163 } else {
164 format!(
165 "Note: provider {name:?} reads its key from ${variable}; the SCV daemon's \
166 environment must provide it (the user service does not load your shell profile)"
167 )
168 },
169 );
170 }
171 notes
172}
173
174pub fn import_grok(source: &Path, destination: &Path) -> Result<Vec<String>> {
181 let source = std::fs::canonicalize(source)
182 .with_context(|| format!("resolve Grok home {}", source.display()))?;
183 std::fs::create_dir_all(destination)
184 .with_context(|| format!("create {}", destination.display()))?;
185 let destination = std::fs::canonicalize(destination)
186 .with_context(|| format!("resolve SCV Grok home {}", destination.display()))?;
187 if source == destination {
188 bail!(
189 "{} is already SCV's Grok home; pass your own Grok home with --from",
190 source.display()
191 );
192 }
193 let text = read_bounded(&source.join("config.toml"))?
194 .ok_or_else(|| anyhow!("no config.toml in {}", source.display()))?;
195 let user: toml::Table = text
197 .parse()
198 .map_err(|_| anyhow!("your Grok config.toml is not valid TOML"))?;
199 let target = destination.join("config.toml");
200 let existing: toml::Table = match read_bounded(&target)? {
201 Some(existing) => existing.parse().map_err(|_| {
202 anyhow!(
203 "SCV's Grok config.toml ({}) is not valid TOML; move it aside and import again",
204 target.display()
205 )
206 })?,
207 None => toml::Table::new(),
208 };
209 let kept: toml::Table = existing
210 .into_iter()
211 .filter(|(key, _)| !user.contains_key(key))
212 .collect();
213 let merged = if kept.values().all(toml::Value::is_table) {
214 let mut merged = text.trim_end().to_owned();
216 merged.push('\n');
217 if !kept.is_empty() {
218 merged.push('\n');
219 merged.push_str(&toml::to_string(&kept).context("serialize kept settings")?);
220 }
221 merged
222 } else {
223 let mut table = user.clone();
226 table.extend(kept.clone());
227 toml::to_string(&table).context("serialize merged Grok config")?
228 };
229 if merged.parse::<toml::Table>().is_err() {
230 bail!("the merged Grok config.toml would not be valid TOML; nothing was written");
231 }
232 write_private(&target, &merged)?;
233
234 let mut notes = vec![format!("Copied config.toml{}", describe_grok(&user))];
235 if !kept.is_empty() {
236 let names: Vec<String> = kept.keys().map(|key| format!("{key:?}")).collect();
237 notes.push(format!("Kept SCV-only settings: {}", names.join(", ")));
238 }
239 match grok_default_key(&user) {
240 GrokKey::InConfig(_) | GrokKey::NoDefault => {}
241 GrokKey::FromVariable(model, variable) => notes.push(grok_variable_note(&model, &variable)),
242 GrokKey::Missing(model) => notes.push(format!(
243 "Note: default model {model:?} has no api_key in config; sign SCV in with \
244 `scv agents login grok` or add api_key to its profile"
245 )),
246 }
247 if source.join("auth.json").exists() {
248 notes.push(
249 "Skipped auth.json: `grok login` sign-ins are not shared; sign SCV in \
250 separately with `scv agents login grok` if you need one"
251 .into(),
252 );
253 }
254 Ok(notes)
255}
256
257fn describe_grok(table: &toml::Table) -> String {
259 let profiles: Vec<String> = table
260 .get("model")
261 .and_then(toml::Value::as_table)
262 .map(|models| models.keys().map(|key| format!("{key:?}")).collect())
263 .unwrap_or_default();
264 let default =
265 grok_default(table).map_or_else(|| "built-in".into(), |model| format!("{model:?}"));
266 if profiles.is_empty() {
267 format!(" (default model {default}; no model profiles)")
268 } else {
269 format!(
270 " (default model {default}; profiles {})",
271 profiles.join(", ")
272 )
273 }
274}
275
276enum GrokKey {
278 NoDefault,
280 InConfig(String),
282 FromVariable(String, Vec<String>),
284 Missing(String),
286}
287
288fn grok_default(table: &toml::Table) -> Option<String> {
289 table
290 .get("models")?
291 .get("default")?
292 .as_str()
293 .map(ToOwned::to_owned)
294}
295
296fn grok_default_key(table: &toml::Table) -> GrokKey {
299 let Some(default) = grok_default(table) else {
300 return GrokKey::NoDefault;
301 };
302 let models = table.get("model").and_then(toml::Value::as_table);
303 let profile = models.and_then(|models| {
304 models.get(&default).or_else(|| {
305 models.values().find(|profile| {
306 profile.get("model").and_then(toml::Value::as_str) == Some(&default)
307 })
308 })
309 });
310 let Some(profile) = profile else {
311 return GrokKey::Missing(default);
312 };
313 if profile
314 .get("api_key")
315 .and_then(toml::Value::as_str)
316 .is_some_and(|key| !key.trim().is_empty())
317 {
318 return GrokKey::InConfig(default);
319 }
320 let variables: Vec<String> = match profile.get("env_key") {
321 Some(toml::Value::String(name)) => vec![name.clone()],
322 Some(toml::Value::Array(names)) => names
323 .iter()
324 .filter_map(toml::Value::as_str)
325 .map(ToOwned::to_owned)
326 .collect(),
327 _ => Vec::new(),
328 };
329 if variables.is_empty() {
330 GrokKey::Missing(default)
331 } else {
332 GrokKey::FromVariable(default, variables)
333 }
334}
335
336fn usable_grok_variable(variables: &[String]) -> Option<&String> {
339 variables.iter().find(|variable| {
340 !super::adapters::is_removed_agent_variable(std::ffi::OsStr::new(variable.as_str()))
341 && std::env::var_os(variable).is_some_and(|value| !value.is_empty())
342 })
343}
344
345fn grok_variable_note(model: &str, variables: &[String]) -> String {
346 let names: Vec<String> = variables.iter().map(|name| format!("${name}")).collect();
347 format!(
348 "Note: default model {model:?} reads its key from {}; SCV removes key variables \
349 from delegated agents and its service does not load your shell profile, so put \
350 api_key in the profile instead",
351 names.join(" or ")
352 )
353}
354
355#[derive(Debug, Clone, PartialEq, Eq)]
358pub struct StoredStatus {
359 pub ready: bool,
360 pub lines: Vec<String>,
361}
362
363pub fn claude_status(auth: AgentAuth, home: &Path) -> Result<StoredStatus> {
365 match auth {
366 AgentAuth::ClaudeApiKey => claude_api_status(
367 &home.join(".anthropic-api-key"),
368 &home.join(".claude/.credentials.json"),
369 home,
370 ),
371 AgentAuth::ClaudeSubscription => claude_account_status(
372 &home.join(".claude/.credentials.json"),
373 &home.join(".claude.json"),
374 home,
375 ),
376 AgentAuth::None => Ok(StoredStatus::ready("not applicable".into())),
377 }
378}
379
380impl StoredStatus {
381 fn ready(line: String) -> Self {
382 Self {
383 ready: true,
384 lines: vec![line],
385 }
386 }
387
388 fn missing(lines: Vec<String>) -> Self {
389 Self {
390 ready: false,
391 lines,
392 }
393 }
394}
395
396fn grok_status(auth: &Path, config: &Path, home: &Path) -> Result<StoredStatus> {
397 let entries = read_json_object(auth)?.map_or(0, |object| {
398 object.values().filter(|value| !value.is_null()).count()
399 });
400 if entries > 0 {
401 return Ok(StoredStatus::ready(format!(
402 "signed in ({})",
403 display(auth, home)
404 )));
405 }
406 let Some(text) = read_bounded(config)? else {
407 return Ok(StoredStatus::missing(vec!["not signed in".into()]));
408 };
409 let table: toml::Table = text
410 .parse()
411 .map_err(|_| anyhow!("{} is not valid TOML", display(config, home)))?;
412 Ok(match grok_default_key(&table) {
413 GrokKey::InConfig(model) => {
414 StoredStatus::ready(format!("signed in (API key in config, model {model:?})"))
415 }
416 GrokKey::FromVariable(model, variables) => match usable_grok_variable(&variables) {
417 Some(variable) => {
418 StoredStatus::ready(format!("signed in (key from ${variable}, model {model:?})"))
419 }
420 None => StoredStatus::missing(vec![
421 "not signed in".into(),
422 grok_variable_note(&model, &variables),
423 ]),
424 },
425 GrokKey::Missing(model) => StoredStatus::missing(vec![
426 "not signed in".into(),
427 format!("default model {model:?} has no api_key in config"),
428 ]),
429 GrokKey::NoDefault => StoredStatus::missing(vec!["not signed in".into()]),
430 })
431}
432
433fn claude_api_status(key: &Path, credentials: &Path, home: &Path) -> Result<StoredStatus> {
434 let metadata = match std::fs::symlink_metadata(key) {
435 Ok(metadata) => metadata,
436 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
437 return Ok(StoredStatus::missing(vec![
438 "not signed in (no API key; run scv agents login claude)".into(),
439 ]));
440 }
441 Err(error) => return Err(anyhow!(error).context(format!("inspect {}", display(key, home)))),
442 };
443 if !metadata.file_type().is_file() {
444 return Ok(StoredStatus::missing(vec![
445 "not signed in (API key file is not a regular file)".into(),
446 ]));
447 }
448 #[cfg(unix)]
449 if metadata.permissions().mode() & 0o077 != 0 {
450 return Ok(StoredStatus::missing(vec![format!(
451 "key file is readable by group or others; run chmod 600 {}",
452 display(key, home)
453 )]));
454 }
455 let text =
456 std::fs::read_to_string(key).with_context(|| format!("read {}", display(key, home)))?;
457 let key_value = text.strip_suffix('\n').unwrap_or(text.as_str());
461 let key_value = key_value.strip_suffix('\r').unwrap_or(key_value);
462 if !(1..=512).contains(&key_value.len())
463 || key_value
464 .chars()
465 .any(|character| character.is_whitespace() || character.is_control())
466 {
467 return Ok(StoredStatus::missing(vec![
468 "not signed in (API key file has an invalid shape; run scv agents login claude)".into(),
469 ]));
470 }
471 if let Some(text) = read_bounded(credentials)? {
472 let value: serde_json::Value = match serde_json::from_str(&text) {
473 Ok(value) => value,
474 Err(_) => {
475 return Ok(StoredStatus::missing(vec![
476 "blocked: Claude credentials file is not valid JSON".into(),
477 ]));
478 }
479 };
480 if value
481 .get("claudeAiOauth")
482 .is_some_and(|oauth| !oauth.is_null())
483 {
484 return Ok(StoredStatus::missing(vec![
485 "blocked: a Claude subscription login is in this home (move it to agents/claude-ai, or run HOME=$SCV_HOME/agents/claude claude auth logout)".into(),
486 ]));
487 }
488 }
489 Ok(StoredStatus::ready("signed in (API key)".into()))
490}
491
492const MAX_CLAUDE_CONFIG_BYTES: u64 = 8 * 1024 * 1024;
497
498#[derive(Debug, Clone, Copy, PartialEq, Eq)]
500enum ConsoleKey {
501 Absent,
502 Present,
503 Unparseable,
504 TooLarge,
506}
507
508#[derive(serde::Deserialize)]
509struct ClaudeConfigFile {
510 #[serde(default, rename = "primaryApiKey")]
511 primary_api_key: Option<String>,
512}
513
514fn console_key(path: &Path) -> Result<ConsoleKey> {
515 let file = match std::fs::File::open(path) {
516 Ok(file) => file,
517 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
518 return Ok(ConsoleKey::Absent);
519 }
520 Err(error) => return Err(anyhow!(error).context(format!("open {}", path.display()))),
521 };
522 let metadata = file
523 .metadata()
524 .with_context(|| format!("inspect {}", path.display()))?;
525 if !metadata.is_file() {
526 bail!("{} is not a regular file", path.display());
527 }
528 if metadata.len() > MAX_CLAUDE_CONFIG_BYTES {
529 return Ok(ConsoleKey::TooLarge);
530 }
531 let reader = std::io::BufReader::new(file.take(MAX_CLAUDE_CONFIG_BYTES));
532 match serde_json::from_reader::<_, ClaudeConfigFile>(reader) {
533 Ok(config) => Ok(
534 if config
535 .primary_api_key
536 .as_deref()
537 .is_some_and(|key| !key.trim().is_empty())
538 {
539 ConsoleKey::Present
540 } else {
541 ConsoleKey::Absent
542 },
543 ),
544 Err(_) => Ok(ConsoleKey::Unparseable),
545 }
546}
547
548fn claude_account_status(credentials: &Path, config: &Path, _home: &Path) -> Result<StoredStatus> {
549 let Some(text) = read_bounded(credentials)? else {
550 return Ok(StoredStatus::missing(vec![
551 "not signed in (run scv agents login claude-ai)".into(),
552 ]));
553 };
554 let value: serde_json::Value = match serde_json::from_str(&text) {
555 Ok(value) => value,
556 Err(_) => {
557 return Ok(StoredStatus::missing(vec![
558 "blocked: Claude credentials file is not valid JSON".into(),
559 ]));
560 }
561 };
562 match console_key(config)? {
563 ConsoleKey::Present => {
564 return Ok(StoredStatus::missing(vec![
565 "blocked: a Console API key is in this home".into(),
566 ]));
567 }
568 ConsoleKey::Unparseable => {
569 return Ok(StoredStatus::missing(vec![
570 "blocked: Claude config file is not valid JSON".into(),
571 ]));
572 }
573 ConsoleKey::TooLarge => {
574 return Ok(StoredStatus::missing(vec![
575 "blocked: Claude config file is too large to confirm it has no Console API key"
576 .into(),
577 ]));
578 }
579 ConsoleKey::Absent => {}
580 }
581 let Some(oauth) = value
582 .get("claudeAiOauth")
583 .and_then(serde_json::Value::as_object)
584 else {
585 return Ok(StoredStatus::missing(vec![
586 "not signed in (run scv agents login claude-ai)".into(),
587 ]));
588 };
589 let usable = oauth
590 .get("refreshToken")
591 .and_then(serde_json::Value::as_str)
592 .is_some_and(|token| !token.trim().is_empty())
593 && oauth
594 .get("scopes")
595 .and_then(serde_json::Value::as_array)
596 .is_some_and(|scopes| {
597 scopes
598 .iter()
599 .any(|scope| scope.as_str() == Some("user:inference"))
600 });
601 if !usable {
602 return Ok(StoredStatus::missing(vec![
603 "not signed in (run scv agents login claude-ai)".into(),
604 ]));
605 }
606 let plan = oauth
607 .get("subscriptionType")
608 .and_then(serde_json::Value::as_str)
609 .filter(|plan| ["free", "pro", "max", "team", "enterprise"].contains(plan))
610 .unwrap_or("unknown");
611 Ok(StoredStatus::ready(format!(
612 "signed in (Claude subscription, {plan})"
613 )))
614}
615
616fn write_private(path: &Path, contents: &str) -> Result<()> {
617 scv_client::fs::replace_private(path, contents.as_bytes())
618 .with_context(|| format!("replace {}", path.display()))
619}
620
621pub const MAX_FIELD_BYTES: usize = 4096;
623
624pub(crate) const PI_PROVIDER: &str = "scv";
626
627#[derive(Debug, Clone, Copy, PartialEq, Eq)]
629pub enum WireApi {
630 Responses,
631 ChatCompletions,
632}
633
634impl WireApi {
635 fn pi_api(self) -> &'static str {
636 match self {
637 Self::Responses => "openai-responses",
638 Self::ChatCompletions => "openai-completions",
639 }
640 }
641}
642
643#[derive(Debug, Clone)]
645pub struct Endpoint {
646 pub base_url: String,
647 pub api: WireApi,
648 pub model: String,
649}
650
651pub fn validate_secret(secret: &str) -> Result<()> {
654 if secret.is_empty() {
655 bail!("no key entered");
656 }
657 if secret.len() > MAX_FIELD_BYTES
658 || secret
659 .chars()
660 .any(|c| c.is_whitespace() || c.is_control() || c == '"' || c == '\\')
661 {
662 bail!("the key must be one line of at most {MAX_FIELD_BYTES} printable characters");
663 }
664 Ok(())
665}
666
667pub fn store_key(store: KeyStore, home: &Path, key: &str) -> Result<Vec<String>> {
669 validate_secret(key)?;
670 match store {
671 KeyStore::ClaudeApiKey { key: relative, .. } => {
672 let path = home.join(relative);
673 create_private_dirs(home, &path)?;
674 write_private(&path, key)?;
675 Ok(vec![format!(
676 "Stored the Claude API key in {}",
677 display(&path, home)
678 )])
679 }
680 KeyStore::ClaudeAccount { .. } => {
681 bail!(
682 "Claude subscription credentials are owned by Claude Code; run `claude auth login --claudeai`"
683 )
684 }
685 KeyStore::DshRefs { path, variable } => {
686 let path = home.join(path);
687 create_private_dirs(home, &path)?;
688 let quoted = serde_json::to_string(key)?;
690 write_private(
691 &path,
692 &format!("version: 1\n\nrefs:\n {variable}: {quoted}\n"),
693 )?;
694 Ok(vec![format!(
695 "Stored the API key as {variable} in {}",
696 display(&path, home)
697 )])
698 }
699 KeyStore::Grok { .. } | KeyStore::Pi { .. } => {
700 bail!("this agent signs in with its own login, not a stored key")
701 }
702 KeyStore::Scv { .. } => {
703 bail!("the nested SCV uses SCV's own provider: run `scv agents import scv`")
704 }
705 }
706}
707
708pub fn stored_status(store: KeyStore, home: &Path) -> Result<StoredStatus> {
710 match store {
711 KeyStore::ClaudeApiKey { key, credentials } => {
712 claude_api_status(&home.join(key), &home.join(credentials), home)
713 }
714 KeyStore::ClaudeAccount {
715 credentials,
716 config,
717 } => claude_account_status(&home.join(credentials), &home.join(config), home),
718 KeyStore::Grok { auth, config } => grok_status(&home.join(auth), &home.join(config), home),
719 KeyStore::DshRefs { path, variable } => {
720 let path = home.join(path);
721 let stored = read_bounded(&path)?.is_some_and(|text| {
722 text.lines().any(|line| {
723 line.trim_start()
724 .strip_prefix(variable)
725 .and_then(|rest| rest.strip_prefix(':'))
726 .is_some_and(|value| !matches!(value.trim(), "" | "\"\"" | "''"))
727 })
728 });
729 Ok(if stored {
730 StoredStatus::ready(format!("API key stored as {variable}"))
731 } else {
732 StoredStatus::missing(vec!["not signed in".into()])
733 })
734 }
735 KeyStore::Pi { dir } => pi_status(&home.join(dir)),
736 KeyStore::Scv { config } => scv_child_status(&home.join(config)),
737 }
738}
739
740pub fn remove_stored(store: KeyStore, home: &Path) -> Result<Vec<String>> {
742 match store {
743 KeyStore::ClaudeApiKey { key, .. } => {
744 let path = home.join(key);
745 Ok(vec![if remove_if_present(&path)? {
746 format!("Removed {}", display(&path, home))
747 } else {
748 "Nothing stored".into()
749 }])
750 }
751 KeyStore::ClaudeAccount { .. } => {
752 bail!(
753 "Claude subscription credentials are owned by Claude Code; run `claude auth logout`"
754 )
755 }
756 KeyStore::Grok { auth: path, .. }
757 | KeyStore::DshRefs { path, .. }
758 | KeyStore::Scv { config: path } => {
759 let path = home.join(path);
760 Ok(vec![if remove_if_present(&path)? {
761 format!("Removed {}", display(&path, home))
762 } else {
763 "Nothing stored".into()
764 }])
765 }
766 KeyStore::Pi { dir } => {
767 let dir = home.join(dir);
768 let mut notes = Vec::new();
769 if remove_if_present(&dir.join("auth.json"))? {
770 notes.push("Removed pi's stored sign-ins (auth.json)".into());
771 }
772 let models = dir.join("models.json");
773 if let Some(mut object) = read_json_object(&models)?
774 && let Some(providers) = object
775 .get_mut("providers")
776 .and_then(serde_json::Value::as_object_mut)
777 && providers.remove(PI_PROVIDER).is_some()
778 {
779 write_json(&models, &object)?;
780 notes.push(format!(
781 "Removed the {PI_PROVIDER} endpoint from models.json"
782 ));
783 }
784 let settings = dir.join("settings.json");
785 if let Some(mut object) = read_json_object(&settings)?
786 && object
787 .get("defaultProvider")
788 .and_then(serde_json::Value::as_str)
789 == Some(PI_PROVIDER)
790 {
791 object.remove("defaultProvider");
792 object.remove("defaultModel");
793 write_json(&settings, &object)?;
794 notes.push("Cleared pi's default model".into());
795 }
796 if notes.is_empty() {
797 notes.push("Nothing stored".into());
798 }
799 Ok(notes)
800 }
801 }
802}
803
804pub(crate) const SCV_CHILD_PROVIDER: &str = "scv";
806
807pub struct ScvChildProvider<'a> {
809 pub wire_api: &'a str,
810 pub model: &'a str,
811 pub base_url: &'a str,
812 pub timeout_seconds: u64,
813 pub headers: &'a std::collections::HashMap<String, scv_client::Secret>,
814 pub hosted_web_search: bool,
816}
817
818pub fn configure_scv_child(
824 home: &Path,
825 provider: &ScvChildProvider<'_>,
826 key: &str,
827) -> Result<Vec<String>> {
828 validate_secret(key)?;
829 let base_url = validate_base_url(provider.base_url)?;
830 if !valid_model_id(provider.model) {
831 bail!("invalid model id {:?}", provider.model);
832 }
833 let path = home.join("config.toml");
834 create_private_dirs(home, &path)?;
835 let mut table: toml::Table = match read_bounded(&path)? {
836 Some(text) => text
837 .parse()
838 .with_context(|| format!("parse existing {}", display(&path, home)))?,
839 None => toml::Table::new(),
840 };
841 let mut selection = toml::Table::new();
842 selection.insert("active".into(), SCV_CHILD_PROVIDER.into());
843 table.insert("provider".into(), selection.into());
844 let mut profile = toml::Table::new();
845 profile.insert("kind".into(), "openai-compatible".into());
846 profile.insert("wire_api".into(), provider.wire_api.into());
847 profile.insert("model".into(), provider.model.into());
848 profile.insert("base_url".into(), base_url.clone().into());
849 profile.insert("api_key".into(), key.into());
850 profile.insert(
851 "timeout_seconds".into(),
852 i64::try_from(provider.timeout_seconds)
853 .unwrap_or(i64::MAX)
854 .into(),
855 );
856 if !provider.headers.is_empty() {
857 let headers: toml::Table = provider
858 .headers
859 .iter()
860 .map(|(name, value)| (name.clone(), value.expose().into()))
861 .collect();
862 profile.insert("headers".into(), headers.into());
863 }
864 let providers = table
865 .entry("providers")
866 .or_insert_with(|| toml::Table::new().into());
867 let Some(providers) = providers.as_table_mut() else {
868 bail!(
869 "{} has a `providers` value that is not a table",
870 display(&path, home)
871 );
872 };
873 providers.insert(SCV_CHILD_PROVIDER.into(), profile.into());
874 if provider.hosted_web_search {
875 let web = table
876 .entry("web")
877 .or_insert_with(|| toml::Table::new().into());
878 let Some(web) = web.as_table_mut() else {
879 bail!(
880 "{} has a `web` value that is not a table",
881 display(&path, home)
882 );
883 };
884 web.insert("search".into(), "provider".into());
885 }
886 let text = toml::to_string(&table).context("encode the nested SCV config")?;
887 text.parse::<toml::Table>()
888 .context("the nested SCV config did not round-trip")?;
889 write_private(&path, &text)?;
890 let mut lines = vec![
891 format!(
892 "Wrote {} (mode 0600): provider {SCV_CHILD_PROVIDER:?}, model {:?} at {}",
893 display(&path, home),
894 provider.model,
895 host(&base_url)
896 ),
897 "The API key is stored in that file and not shown".into(),
898 ];
899 if provider.hosted_web_search {
900 lines.push("Hosted web search is on, as in SCV's own config".into());
901 }
902 Ok(lines)
903}
904
905fn scv_child_status(path: &Path) -> Result<StoredStatus> {
907 let Some(text) = read_bounded(path)? else {
908 return Ok(StoredStatus::missing(vec![
909 "not configured: run `scv agents import scv`".into(),
910 ]));
911 };
912 let Ok(table) = text.parse::<toml::Table>() else {
913 return Ok(StoredStatus::missing(vec![
914 "config.toml is not valid TOML".into(),
915 ]));
916 };
917 let active = table
918 .get("provider")
919 .and_then(|provider| provider.get("active"))
920 .and_then(toml::Value::as_str);
921 let profile = active.and_then(|active| {
922 table
923 .get("providers")
924 .and_then(|providers| providers.get(active))
925 .and_then(toml::Value::as_table)
926 });
927 let (Some(active), Some(profile)) = (active, profile) else {
928 return Ok(StoredStatus::missing(vec![
929 "no active provider: run `scv agents import scv`".into(),
930 ]));
931 };
932 let text = |key: &str| profile.get(key).and_then(toml::Value::as_str);
933 let keyed = text("api_key").is_some_and(|key| !key.trim().is_empty());
934 let location = format!(
935 "provider {active:?}: model {:?} at {}",
936 text("model").unwrap_or("unset"),
937 text("base_url").map_or_else(|| "no base URL".into(), host)
938 );
939 Ok(if keyed {
940 StoredStatus::ready(format!("{location}, API key stored"))
941 } else {
942 StoredStatus::missing(vec![format!(
943 "{location}, no stored API key (a key variable is not inherited): run `scv agents import scv`"
944 )])
945 })
946}
947
948pub fn configure_pi_endpoint(dir: &Path, endpoint: &Endpoint, key: &str) -> Result<Vec<String>> {
952 validate_secret(key)?;
953 let base_url = validate_base_url(&endpoint.base_url)?;
954 if !valid_model_id(&endpoint.model) {
955 bail!("invalid model id {:?}", endpoint.model);
956 }
957 create_private_dirs(dir, &dir.join("auth.json"))?;
958 let mut models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
960 let mut auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
961 let mut settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
962
963 let providers = models
964 .entry("providers")
965 .or_insert_with(|| serde_json::json!({}));
966 let providers = providers
967 .as_object_mut()
968 .ok_or_else(|| anyhow!("pi models.json has a non-object \"providers\""))?;
969 let mut provider = serde_json::json!({
970 "baseUrl": base_url,
971 "api": endpoint.api.pi_api(),
972 "models": [{"id": endpoint.model}],
973 });
974 if endpoint.api == WireApi::Responses {
975 provider["compat"] = serde_json::json!({"sessionAffinityFormat": "openai-nosession"});
979 }
980 providers.insert(PI_PROVIDER.into(), provider);
981 auth.insert(
982 PI_PROVIDER.into(),
983 serde_json::json!({"type": "api_key", "key": key}),
984 );
985 settings.insert("defaultProvider".into(), PI_PROVIDER.into());
986 settings.insert("defaultModel".into(), endpoint.model.clone().into());
987
988 write_json(&dir.join("models.json"), &models)?;
989 write_json(&dir.join("auth.json"), &auth)?;
990 write_json(&dir.join("settings.json"), &settings)?;
991 Ok(vec![
992 format!(
993 "Configured pi provider {PI_PROVIDER:?}: {} at {}, model {:?}",
994 endpoint.api.pi_api(),
995 host(&base_url),
996 endpoint.model
997 ),
998 "Stored its API key in pi's auth.json (mode 0600) and made it pi's default".into(),
999 ])
1000}
1001
1002fn pi_status(dir: &Path) -> Result<StoredStatus> {
1003 let settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
1004 let auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
1005 let models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
1006 let mut lines = Vec::new();
1007 let text = |object: &serde_json::Map<String, serde_json::Value>, key: &str| {
1008 object
1009 .get(key)
1010 .and_then(serde_json::Value::as_str)
1011 .map(ToOwned::to_owned)
1012 };
1013 let provider = text(&settings, "defaultProvider");
1014 if let Some(provider) = &provider {
1015 let endpoint = models
1016 .get("providers")
1017 .and_then(|providers| providers.get(provider))
1018 .and_then(serde_json::Value::as_object);
1019 let location = endpoint
1020 .map(|endpoint| {
1021 format!(
1022 " ({} at {})",
1023 text(endpoint, "api").unwrap_or_else(|| "api unset".into()),
1024 text(endpoint, "baseUrl")
1025 .map_or_else(|| "no base URL".into(), |url| host(&url))
1026 )
1027 })
1028 .unwrap_or_default();
1029 lines.push(format!(
1030 "default provider {provider:?}{location}, model {:?}",
1031 text(&settings, "defaultModel").unwrap_or_else(|| "unset".into())
1032 ));
1033 }
1034 let mut signed_in: Vec<&String> = auth.keys().collect();
1035 signed_in.sort();
1036 let ready = !signed_in.is_empty();
1037 if ready {
1038 let names: Vec<String> = signed_in.iter().map(|name| format!("{name:?}")).collect();
1039 lines.push(format!("stored sign-ins: {}", names.join(", ")));
1040 } else {
1041 lines.push("not signed in".into());
1042 }
1043 Ok(StoredStatus { ready, lines })
1044}
1045
1046fn validate_base_url(value: &str) -> Result<String> {
1047 let value = value.trim().trim_end_matches('/');
1048 let rest = value
1049 .strip_prefix("https://")
1050 .or_else(|| value.strip_prefix("http://"))
1051 .ok_or_else(|| anyhow!("the base URL must start with https:// or http://"))?;
1052 let authority = rest.split('/').next().unwrap_or_default();
1053 if authority.is_empty()
1054 || authority.contains('@')
1055 || value.len() > MAX_FIELD_BYTES
1056 || value
1057 .chars()
1058 .any(|c| c.is_whitespace() || c.is_control() || matches!(c, '?' | '#'))
1059 {
1060 bail!("the base URL must be a plain http(s) URL without credentials, query, or fragment");
1061 }
1062 Ok(value.to_owned())
1063}
1064
1065fn host(url: &str) -> String {
1067 url.split("://")
1068 .nth(1)
1069 .and_then(|rest| rest.split('/').next())
1070 .unwrap_or(url)
1071 .to_owned()
1072}
1073
1074fn valid_model_id(model: &str) -> bool {
1075 !model.is_empty()
1076 && model.len() <= 128
1077 && !model.starts_with(['-', '@'])
1078 && model
1079 .chars()
1080 .all(|c| c.is_ascii_alphanumeric() || "._:/@[]-".contains(c))
1081}
1082
1083fn display(path: &Path, home: &Path) -> String {
1084 path.strip_prefix(home).map_or_else(
1085 |_| path.display().to_string(),
1086 |relative| relative.display().to_string(),
1087 )
1088}
1089
1090fn read_json_object(path: &Path) -> Result<Option<serde_json::Map<String, serde_json::Value>>> {
1091 let Some(text) = read_bounded(path)? else {
1092 return Ok(None);
1093 };
1094 if text.trim().is_empty() {
1095 return Ok(Some(serde_json::Map::new()));
1096 }
1097 match serde_json::from_str(&text) {
1098 Ok(serde_json::Value::Object(object)) => Ok(Some(object)),
1099 _ => bail!("{} is not a JSON object", path.display()),
1101 }
1102}
1103
1104fn write_json(path: &Path, object: &serde_json::Map<String, serde_json::Value>) -> Result<()> {
1105 write_private(
1106 path,
1107 &format!("{}\n", serde_json::to_string_pretty(object)?),
1108 )
1109}
1110
1111fn remove_if_present(path: &Path) -> Result<bool> {
1112 match std::fs::remove_file(path) {
1113 Ok(()) => Ok(true),
1114 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1115 Err(error) => Err(anyhow!(error).context(format!("remove {}", path.display()))),
1116 }
1117}
1118
1119fn create_private_dirs(root: &Path, file: &Path) -> Result<()> {
1121 let parent = file
1122 .parent()
1123 .ok_or_else(|| anyhow!("{} has no parent", file.display()))?;
1124 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
1125 use std::os::unix::fs::PermissionsExt as _;
1126 let mut dir = parent;
1127 loop {
1128 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
1129 match dir.parent() {
1130 Some(next) if next.starts_with(root) && next != root => dir = next,
1131 _ => break,
1132 }
1133 }
1134 Ok(())
1135}
1136
1137#[cfg(test)]
1138mod tests;