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.trim();
458 if !(1..=512).contains(&key_value.len())
459 || !key_value.starts_with("sk-ant-")
460 || key_value
461 .chars()
462 .any(|character| character.is_whitespace() || character.is_control())
463 {
464 return Ok(StoredStatus::missing(vec![
465 "not signed in (API key file has an invalid shape; run scv agents login claude)".into(),
466 ]));
467 }
468 if let Some(text) = read_bounded(credentials)? {
469 let value: serde_json::Value = match serde_json::from_str(&text) {
470 Ok(value) => value,
471 Err(_) => {
472 return Ok(StoredStatus::missing(vec![
473 "blocked: Claude credentials file is not valid JSON".into(),
474 ]));
475 }
476 };
477 if value
478 .get("claudeAiOauth")
479 .is_some_and(|oauth| !oauth.is_null())
480 {
481 return Ok(StoredStatus::missing(vec![
482 "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(),
483 ]));
484 }
485 }
486 Ok(StoredStatus::ready("signed in (API key)".into()))
487}
488
489const MAX_CLAUDE_CONFIG_BYTES: u64 = 8 * 1024 * 1024;
494
495#[derive(Debug, Clone, Copy, PartialEq, Eq)]
497enum ConsoleKey {
498 Absent,
499 Present,
500 Unparseable,
501 TooLarge,
503}
504
505#[derive(serde::Deserialize)]
506struct ClaudeConfigFile {
507 #[serde(default, rename = "primaryApiKey")]
508 primary_api_key: Option<String>,
509}
510
511fn console_key(path: &Path) -> Result<ConsoleKey> {
512 let file = match std::fs::File::open(path) {
513 Ok(file) => file,
514 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
515 return Ok(ConsoleKey::Absent);
516 }
517 Err(error) => return Err(anyhow!(error).context(format!("open {}", path.display()))),
518 };
519 let metadata = file
520 .metadata()
521 .with_context(|| format!("inspect {}", path.display()))?;
522 if !metadata.is_file() {
523 bail!("{} is not a regular file", path.display());
524 }
525 if metadata.len() > MAX_CLAUDE_CONFIG_BYTES {
526 return Ok(ConsoleKey::TooLarge);
527 }
528 let reader = std::io::BufReader::new(file.take(MAX_CLAUDE_CONFIG_BYTES));
529 match serde_json::from_reader::<_, ClaudeConfigFile>(reader) {
530 Ok(config) => Ok(
531 if config
532 .primary_api_key
533 .as_deref()
534 .is_some_and(|key| !key.trim().is_empty())
535 {
536 ConsoleKey::Present
537 } else {
538 ConsoleKey::Absent
539 },
540 ),
541 Err(_) => Ok(ConsoleKey::Unparseable),
542 }
543}
544
545fn claude_account_status(credentials: &Path, config: &Path, _home: &Path) -> Result<StoredStatus> {
546 let Some(text) = read_bounded(credentials)? else {
547 return Ok(StoredStatus::missing(vec![
548 "not signed in (run scv agents login claude-ai)".into(),
549 ]));
550 };
551 let value: serde_json::Value = match serde_json::from_str(&text) {
552 Ok(value) => value,
553 Err(_) => {
554 return Ok(StoredStatus::missing(vec![
555 "blocked: Claude credentials file is not valid JSON".into(),
556 ]));
557 }
558 };
559 match console_key(config)? {
560 ConsoleKey::Present => {
561 return Ok(StoredStatus::missing(vec![
562 "blocked: a Console API key is in this home".into(),
563 ]));
564 }
565 ConsoleKey::Unparseable => {
566 return Ok(StoredStatus::missing(vec![
567 "blocked: Claude config file is not valid JSON".into(),
568 ]));
569 }
570 ConsoleKey::TooLarge => {
571 return Ok(StoredStatus::missing(vec![
572 "blocked: Claude config file is too large to confirm it has no Console API key"
573 .into(),
574 ]));
575 }
576 ConsoleKey::Absent => {}
577 }
578 let Some(oauth) = value
579 .get("claudeAiOauth")
580 .and_then(serde_json::Value::as_object)
581 else {
582 return Ok(StoredStatus::missing(vec![
583 "not signed in (run scv agents login claude-ai)".into(),
584 ]));
585 };
586 let usable = oauth
587 .get("refreshToken")
588 .and_then(serde_json::Value::as_str)
589 .is_some_and(|token| !token.trim().is_empty())
590 && oauth
591 .get("scopes")
592 .and_then(serde_json::Value::as_array)
593 .is_some_and(|scopes| {
594 scopes
595 .iter()
596 .any(|scope| scope.as_str() == Some("user:inference"))
597 });
598 if !usable {
599 return Ok(StoredStatus::missing(vec![
600 "not signed in (run scv agents login claude-ai)".into(),
601 ]));
602 }
603 let plan = oauth
604 .get("subscriptionType")
605 .and_then(serde_json::Value::as_str)
606 .filter(|plan| ["free", "pro", "max", "team", "enterprise"].contains(plan))
607 .unwrap_or("unknown");
608 Ok(StoredStatus::ready(format!(
609 "signed in (Claude subscription, {plan})"
610 )))
611}
612
613fn write_private(path: &Path, contents: &str) -> Result<()> {
614 scv_client::fs::replace_private(path, contents.as_bytes())
615 .with_context(|| format!("replace {}", path.display()))
616}
617
618pub const MAX_FIELD_BYTES: usize = 4096;
620
621pub(crate) const PI_PROVIDER: &str = "scv";
623
624#[derive(Debug, Clone, Copy, PartialEq, Eq)]
626pub enum WireApi {
627 Responses,
628 ChatCompletions,
629}
630
631impl WireApi {
632 fn pi_api(self) -> &'static str {
633 match self {
634 Self::Responses => "openai-responses",
635 Self::ChatCompletions => "openai-completions",
636 }
637 }
638}
639
640#[derive(Debug, Clone)]
642pub struct Endpoint {
643 pub base_url: String,
644 pub api: WireApi,
645 pub model: String,
646}
647
648pub fn validate_secret(secret: &str) -> Result<()> {
651 if secret.is_empty() {
652 bail!("no key entered");
653 }
654 if secret.len() > MAX_FIELD_BYTES
655 || secret
656 .chars()
657 .any(|c| c.is_whitespace() || c.is_control() || c == '"' || c == '\\')
658 {
659 bail!("the key must be one line of at most {MAX_FIELD_BYTES} printable characters");
660 }
661 Ok(())
662}
663
664pub fn store_key(store: KeyStore, home: &Path, key: &str) -> Result<Vec<String>> {
666 validate_secret(key)?;
667 match store {
668 KeyStore::ClaudeApiKey { key: relative, .. } => {
669 let path = home.join(relative);
670 create_private_dirs(home, &path)?;
671 write_private(&path, key)?;
672 Ok(vec![format!(
673 "Stored the Claude API key in {}",
674 display(&path, home)
675 )])
676 }
677 KeyStore::ClaudeAccount { .. } => {
678 bail!(
679 "Claude subscription credentials are owned by Claude Code; run `claude auth login --claudeai`"
680 )
681 }
682 KeyStore::DshRefs { path, variable } => {
683 let path = home.join(path);
684 create_private_dirs(home, &path)?;
685 let quoted = serde_json::to_string(key)?;
687 write_private(
688 &path,
689 &format!("version: 1\n\nrefs:\n {variable}: {quoted}\n"),
690 )?;
691 Ok(vec![format!(
692 "Stored the API key as {variable} in {}",
693 display(&path, home)
694 )])
695 }
696 KeyStore::Grok { .. } | KeyStore::Pi { .. } => {
697 bail!("this agent signs in with its own login, not a stored key")
698 }
699 KeyStore::Scv { .. } => {
700 bail!("the nested SCV uses SCV's own provider: run `scv agents import scv`")
701 }
702 }
703}
704
705pub fn stored_status(store: KeyStore, home: &Path) -> Result<StoredStatus> {
707 match store {
708 KeyStore::ClaudeApiKey { key, credentials } => {
709 claude_api_status(&home.join(key), &home.join(credentials), home)
710 }
711 KeyStore::ClaudeAccount {
712 credentials,
713 config,
714 } => claude_account_status(&home.join(credentials), &home.join(config), home),
715 KeyStore::Grok { auth, config } => grok_status(&home.join(auth), &home.join(config), home),
716 KeyStore::DshRefs { path, variable } => {
717 let path = home.join(path);
718 let stored = read_bounded(&path)?.is_some_and(|text| {
719 text.lines().any(|line| {
720 line.trim_start()
721 .strip_prefix(variable)
722 .and_then(|rest| rest.strip_prefix(':'))
723 .is_some_and(|value| !matches!(value.trim(), "" | "\"\"" | "''"))
724 })
725 });
726 Ok(if stored {
727 StoredStatus::ready(format!("API key stored as {variable}"))
728 } else {
729 StoredStatus::missing(vec!["not signed in".into()])
730 })
731 }
732 KeyStore::Pi { dir } => pi_status(&home.join(dir)),
733 KeyStore::Scv { config } => scv_child_status(&home.join(config)),
734 }
735}
736
737pub fn remove_stored(store: KeyStore, home: &Path) -> Result<Vec<String>> {
739 match store {
740 KeyStore::ClaudeApiKey { key, .. } => {
741 let path = home.join(key);
742 Ok(vec![if remove_if_present(&path)? {
743 format!("Removed {}", display(&path, home))
744 } else {
745 "Nothing stored".into()
746 }])
747 }
748 KeyStore::ClaudeAccount { .. } => {
749 bail!(
750 "Claude subscription credentials are owned by Claude Code; run `claude auth logout`"
751 )
752 }
753 KeyStore::Grok { auth: path, .. }
754 | KeyStore::DshRefs { path, .. }
755 | KeyStore::Scv { config: path } => {
756 let path = home.join(path);
757 Ok(vec![if remove_if_present(&path)? {
758 format!("Removed {}", display(&path, home))
759 } else {
760 "Nothing stored".into()
761 }])
762 }
763 KeyStore::Pi { dir } => {
764 let dir = home.join(dir);
765 let mut notes = Vec::new();
766 if remove_if_present(&dir.join("auth.json"))? {
767 notes.push("Removed pi's stored sign-ins (auth.json)".into());
768 }
769 let models = dir.join("models.json");
770 if let Some(mut object) = read_json_object(&models)?
771 && let Some(providers) = object
772 .get_mut("providers")
773 .and_then(serde_json::Value::as_object_mut)
774 && providers.remove(PI_PROVIDER).is_some()
775 {
776 write_json(&models, &object)?;
777 notes.push(format!(
778 "Removed the {PI_PROVIDER} endpoint from models.json"
779 ));
780 }
781 let settings = dir.join("settings.json");
782 if let Some(mut object) = read_json_object(&settings)?
783 && object
784 .get("defaultProvider")
785 .and_then(serde_json::Value::as_str)
786 == Some(PI_PROVIDER)
787 {
788 object.remove("defaultProvider");
789 object.remove("defaultModel");
790 write_json(&settings, &object)?;
791 notes.push("Cleared pi's default model".into());
792 }
793 if notes.is_empty() {
794 notes.push("Nothing stored".into());
795 }
796 Ok(notes)
797 }
798 }
799}
800
801pub(crate) const SCV_CHILD_PROVIDER: &str = "scv";
803
804pub struct ScvChildProvider<'a> {
806 pub wire_api: &'a str,
807 pub model: &'a str,
808 pub base_url: &'a str,
809 pub timeout_seconds: u64,
810 pub headers: &'a std::collections::HashMap<String, scv_client::Secret>,
811 pub hosted_web_search: bool,
813}
814
815pub fn configure_scv_child(
821 home: &Path,
822 provider: &ScvChildProvider<'_>,
823 key: &str,
824) -> Result<Vec<String>> {
825 validate_secret(key)?;
826 let base_url = validate_base_url(provider.base_url)?;
827 if !valid_model_id(provider.model) {
828 bail!("invalid model id {:?}", provider.model);
829 }
830 let path = home.join("config.toml");
831 create_private_dirs(home, &path)?;
832 let mut table: toml::Table = match read_bounded(&path)? {
833 Some(text) => text
834 .parse()
835 .with_context(|| format!("parse existing {}", display(&path, home)))?,
836 None => toml::Table::new(),
837 };
838 let mut selection = toml::Table::new();
839 selection.insert("active".into(), SCV_CHILD_PROVIDER.into());
840 table.insert("provider".into(), selection.into());
841 let mut profile = toml::Table::new();
842 profile.insert("kind".into(), "openai-compatible".into());
843 profile.insert("wire_api".into(), provider.wire_api.into());
844 profile.insert("model".into(), provider.model.into());
845 profile.insert("base_url".into(), base_url.clone().into());
846 profile.insert("api_key".into(), key.into());
847 profile.insert(
848 "timeout_seconds".into(),
849 i64::try_from(provider.timeout_seconds)
850 .unwrap_or(i64::MAX)
851 .into(),
852 );
853 if !provider.headers.is_empty() {
854 let headers: toml::Table = provider
855 .headers
856 .iter()
857 .map(|(name, value)| (name.clone(), value.expose().into()))
858 .collect();
859 profile.insert("headers".into(), headers.into());
860 }
861 let providers = table
862 .entry("providers")
863 .or_insert_with(|| toml::Table::new().into());
864 let Some(providers) = providers.as_table_mut() else {
865 bail!(
866 "{} has a `providers` value that is not a table",
867 display(&path, home)
868 );
869 };
870 providers.insert(SCV_CHILD_PROVIDER.into(), profile.into());
871 if provider.hosted_web_search {
872 let web = table
873 .entry("web")
874 .or_insert_with(|| toml::Table::new().into());
875 let Some(web) = web.as_table_mut() else {
876 bail!(
877 "{} has a `web` value that is not a table",
878 display(&path, home)
879 );
880 };
881 web.insert("search".into(), "provider".into());
882 }
883 let text = toml::to_string(&table).context("encode the nested SCV config")?;
884 text.parse::<toml::Table>()
885 .context("the nested SCV config did not round-trip")?;
886 write_private(&path, &text)?;
887 let mut lines = vec![
888 format!(
889 "Wrote {} (mode 0600): provider {SCV_CHILD_PROVIDER:?}, model {:?} at {}",
890 display(&path, home),
891 provider.model,
892 host(&base_url)
893 ),
894 "The API key is stored in that file and not shown".into(),
895 ];
896 if provider.hosted_web_search {
897 lines.push("Hosted web search is on, as in SCV's own config".into());
898 }
899 Ok(lines)
900}
901
902fn scv_child_status(path: &Path) -> Result<StoredStatus> {
904 let Some(text) = read_bounded(path)? else {
905 return Ok(StoredStatus::missing(vec![
906 "not configured: run `scv agents import scv`".into(),
907 ]));
908 };
909 let Ok(table) = text.parse::<toml::Table>() else {
910 return Ok(StoredStatus::missing(vec![
911 "config.toml is not valid TOML".into(),
912 ]));
913 };
914 let active = table
915 .get("provider")
916 .and_then(|provider| provider.get("active"))
917 .and_then(toml::Value::as_str);
918 let profile = active.and_then(|active| {
919 table
920 .get("providers")
921 .and_then(|providers| providers.get(active))
922 .and_then(toml::Value::as_table)
923 });
924 let (Some(active), Some(profile)) = (active, profile) else {
925 return Ok(StoredStatus::missing(vec![
926 "no active provider: run `scv agents import scv`".into(),
927 ]));
928 };
929 let text = |key: &str| profile.get(key).and_then(toml::Value::as_str);
930 let keyed = text("api_key").is_some_and(|key| !key.trim().is_empty());
931 let location = format!(
932 "provider {active:?}: model {:?} at {}",
933 text("model").unwrap_or("unset"),
934 text("base_url").map_or_else(|| "no base URL".into(), host)
935 );
936 Ok(if keyed {
937 StoredStatus::ready(format!("{location}, API key stored"))
938 } else {
939 StoredStatus::missing(vec![format!(
940 "{location}, no stored API key (a key variable is not inherited): run `scv agents import scv`"
941 )])
942 })
943}
944
945pub fn configure_pi_endpoint(dir: &Path, endpoint: &Endpoint, key: &str) -> Result<Vec<String>> {
949 validate_secret(key)?;
950 let base_url = validate_base_url(&endpoint.base_url)?;
951 if !valid_model_id(&endpoint.model) {
952 bail!("invalid model id {:?}", endpoint.model);
953 }
954 create_private_dirs(dir, &dir.join("auth.json"))?;
955 let mut models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
957 let mut auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
958 let mut settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
959
960 let providers = models
961 .entry("providers")
962 .or_insert_with(|| serde_json::json!({}));
963 let providers = providers
964 .as_object_mut()
965 .ok_or_else(|| anyhow!("pi models.json has a non-object \"providers\""))?;
966 let mut provider = serde_json::json!({
967 "baseUrl": base_url,
968 "api": endpoint.api.pi_api(),
969 "models": [{"id": endpoint.model}],
970 });
971 if endpoint.api == WireApi::Responses {
972 provider["compat"] = serde_json::json!({"sessionAffinityFormat": "openai-nosession"});
976 }
977 providers.insert(PI_PROVIDER.into(), provider);
978 auth.insert(
979 PI_PROVIDER.into(),
980 serde_json::json!({"type": "api_key", "key": key}),
981 );
982 settings.insert("defaultProvider".into(), PI_PROVIDER.into());
983 settings.insert("defaultModel".into(), endpoint.model.clone().into());
984
985 write_json(&dir.join("models.json"), &models)?;
986 write_json(&dir.join("auth.json"), &auth)?;
987 write_json(&dir.join("settings.json"), &settings)?;
988 Ok(vec![
989 format!(
990 "Configured pi provider {PI_PROVIDER:?}: {} at {}, model {:?}",
991 endpoint.api.pi_api(),
992 host(&base_url),
993 endpoint.model
994 ),
995 "Stored its API key in pi's auth.json (mode 0600) and made it pi's default".into(),
996 ])
997}
998
999fn pi_status(dir: &Path) -> Result<StoredStatus> {
1000 let settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
1001 let auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
1002 let models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
1003 let mut lines = Vec::new();
1004 let text = |object: &serde_json::Map<String, serde_json::Value>, key: &str| {
1005 object
1006 .get(key)
1007 .and_then(serde_json::Value::as_str)
1008 .map(ToOwned::to_owned)
1009 };
1010 let provider = text(&settings, "defaultProvider");
1011 if let Some(provider) = &provider {
1012 let endpoint = models
1013 .get("providers")
1014 .and_then(|providers| providers.get(provider))
1015 .and_then(serde_json::Value::as_object);
1016 let location = endpoint
1017 .map(|endpoint| {
1018 format!(
1019 " ({} at {})",
1020 text(endpoint, "api").unwrap_or_else(|| "api unset".into()),
1021 text(endpoint, "baseUrl")
1022 .map_or_else(|| "no base URL".into(), |url| host(&url))
1023 )
1024 })
1025 .unwrap_or_default();
1026 lines.push(format!(
1027 "default provider {provider:?}{location}, model {:?}",
1028 text(&settings, "defaultModel").unwrap_or_else(|| "unset".into())
1029 ));
1030 }
1031 let mut signed_in: Vec<&String> = auth.keys().collect();
1032 signed_in.sort();
1033 let ready = !signed_in.is_empty();
1034 if ready {
1035 let names: Vec<String> = signed_in.iter().map(|name| format!("{name:?}")).collect();
1036 lines.push(format!("stored sign-ins: {}", names.join(", ")));
1037 } else {
1038 lines.push("not signed in".into());
1039 }
1040 Ok(StoredStatus { ready, lines })
1041}
1042
1043fn validate_base_url(value: &str) -> Result<String> {
1044 let value = value.trim().trim_end_matches('/');
1045 let rest = value
1046 .strip_prefix("https://")
1047 .or_else(|| value.strip_prefix("http://"))
1048 .ok_or_else(|| anyhow!("the base URL must start with https:// or http://"))?;
1049 let authority = rest.split('/').next().unwrap_or_default();
1050 if authority.is_empty()
1051 || authority.contains('@')
1052 || value.len() > MAX_FIELD_BYTES
1053 || value
1054 .chars()
1055 .any(|c| c.is_whitespace() || c.is_control() || matches!(c, '?' | '#'))
1056 {
1057 bail!("the base URL must be a plain http(s) URL without credentials, query, or fragment");
1058 }
1059 Ok(value.to_owned())
1060}
1061
1062fn host(url: &str) -> String {
1064 url.split("://")
1065 .nth(1)
1066 .and_then(|rest| rest.split('/').next())
1067 .unwrap_or(url)
1068 .to_owned()
1069}
1070
1071fn valid_model_id(model: &str) -> bool {
1072 !model.is_empty()
1073 && model.len() <= 128
1074 && !model.starts_with(['-', '@'])
1075 && model
1076 .chars()
1077 .all(|c| c.is_ascii_alphanumeric() || "._:/@[]-".contains(c))
1078}
1079
1080fn display(path: &Path, home: &Path) -> String {
1081 path.strip_prefix(home).map_or_else(
1082 |_| path.display().to_string(),
1083 |relative| relative.display().to_string(),
1084 )
1085}
1086
1087fn read_json_object(path: &Path) -> Result<Option<serde_json::Map<String, serde_json::Value>>> {
1088 let Some(text) = read_bounded(path)? else {
1089 return Ok(None);
1090 };
1091 if text.trim().is_empty() {
1092 return Ok(Some(serde_json::Map::new()));
1093 }
1094 match serde_json::from_str(&text) {
1095 Ok(serde_json::Value::Object(object)) => Ok(Some(object)),
1096 _ => bail!("{} is not a JSON object", path.display()),
1098 }
1099}
1100
1101fn write_json(path: &Path, object: &serde_json::Map<String, serde_json::Value>) -> Result<()> {
1102 write_private(
1103 path,
1104 &format!("{}\n", serde_json::to_string_pretty(object)?),
1105 )
1106}
1107
1108fn remove_if_present(path: &Path) -> Result<bool> {
1109 match std::fs::remove_file(path) {
1110 Ok(()) => Ok(true),
1111 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1112 Err(error) => Err(anyhow!(error).context(format!("remove {}", path.display()))),
1113 }
1114}
1115
1116fn create_private_dirs(root: &Path, file: &Path) -> Result<()> {
1118 let parent = file
1119 .parent()
1120 .ok_or_else(|| anyhow!("{} has no parent", file.display()))?;
1121 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
1122 use std::os::unix::fs::PermissionsExt as _;
1123 let mut dir = parent;
1124 loop {
1125 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
1126 match dir.parent() {
1127 Some(next) if next.starts_with(root) && next != root => dir = next,
1128 _ => break,
1129 }
1130 }
1131 Ok(())
1132}
1133
1134#[cfg(test)]
1135mod tests;