Skip to main content

ai_usagebar/modelstudio/
creds.rs

1//! Read the official `bl` CLI's own console-login file — read-only, never
2//! written. `~/.bailian/config.json` (flat snake_case JSON) holds the console
3//! Bearer token the CLI stored after `bl auth login --console`:
4//!
5//! ```json
6//! {"access_token": "ey…", "console_site": "domestic",
7//!  "console_region": "cn-beijing"}
8//! ```
9//!
10//! A camelCase `accessToken` alias is accepted — the CLI has spelled it both
11//! ways. AK/SK refresh is out of scope: the token is only ever read.
12//!
13//! Error messages are fixed strings: every input here is a credential and
14//! must never end up in a tooltip or a log line.
15
16use std::path::Path;
17
18use crate::error::{AppError, Result};
19
20use super::types::{ConsoleRegion, ConsoleSite};
21
22/// The console session, as the CLI stored it.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Credentials {
25    /// Bearer console token. Sent in one outgoing header, never persisted.
26    pub access_token: String,
27    pub site: ConsoleSite,
28    pub region: ConsoleRegion,
29    /// First 16 hex chars of the SHA-256 of the access token. Scopes the
30    /// vendor cache to this login, so a re-login never gets the previous
31    /// session's figures — the kiro/minimax/grokbot treatment.
32    pub fingerprint: String,
33}
34
35#[derive(serde::Deserialize)]
36struct ConfigFile {
37    #[serde(default, alias = "accessToken")]
38    access_token: Option<String>,
39    #[serde(default)]
40    console_site: Option<String>,
41    #[serde(default)]
42    console_region: Option<String>,
43}
44
45/// Read and parse the CLI's config file at `path`. A missing file, a missing
46/// token, and an unparseable file are all Credentials errors naming the CLI's
47/// own fix — there is nothing else the user can do.
48pub fn read_from(path: &Path) -> Result<Credentials> {
49    let raw = std::fs::read(path).map_err(|e| {
50        if e.kind() == std::io::ErrorKind::NotFound {
51            AppError::Credentials(format!(
52                "Model Studio: no bl CLI config at {}; install the official `bl` CLI and \
53                 run `bl auth login --console` to re-auth",
54                crate::display::sanitize_untrusted_path(path)
55            ))
56        } else {
57            AppError::io_at(path, e)
58        }
59    })?;
60    parse(&raw)
61}
62
63fn parse(raw: &[u8]) -> Result<Credentials> {
64    let file: ConfigFile = serde_json::from_slice(raw).map_err(|_| malformed())?;
65    let access_token = file
66        .access_token
67        .filter(|token| !token.trim().is_empty())
68        .ok_or_else(malformed)?;
69    Ok(Credentials {
70        fingerprint: crate::cache::fingerprint_of(&access_token),
71        site: ConsoleSite::parse(file.console_site.as_deref().unwrap_or("domestic")),
72        region: ConsoleRegion::parse(file.console_region.as_deref().unwrap_or("cn-beijing")),
73        access_token,
74    })
75}
76
77fn malformed() -> AppError {
78    AppError::Credentials(
79        "Model Studio: the bl CLI's config.json carries no console token; \
80         run `bl auth login --console` to re-auth"
81            .into(),
82    )
83}
84
85/// The detection probe: a present, non-empty config file. Cheap enough to run
86/// at every frontend start — no parse, no network.
87pub fn config_present_at(path: &Path) -> bool {
88    std::fs::metadata(path).is_ok_and(|meta| meta.is_file() && meta.len() > 0)
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use tempfile::TempDir;
95
96    fn write_config(dir: &TempDir, contents: &str) -> std::path::PathBuf {
97        let path = dir.path().join("config.json");
98        std::fs::write(&path, contents).unwrap();
99        path
100    }
101
102    #[test]
103    fn a_snake_case_config_yields_the_console_session() {
104        let td = TempDir::new().unwrap();
105        let path = write_config(
106            &td,
107            r#"{"access_token":"tok-1","console_site":"international",
108                "console_region":"ap-southeast-1"}"#,
109        );
110        let creds = read_from(&path).unwrap();
111        assert_eq!(creds.access_token, "tok-1");
112        assert_eq!(creds.site, ConsoleSite::International);
113        assert_eq!(creds.region, ConsoleRegion::ApSoutheast1);
114        assert_eq!(creds.fingerprint, crate::cache::fingerprint_of("tok-1"));
115        assert_eq!(creds.fingerprint.len(), 16);
116        assert!(creds.fingerprint.chars().all(|c| c.is_ascii_hexdigit()));
117    }
118
119    /// The CLI has spelled the token field both ways; either must work.
120    #[test]
121    fn a_camel_case_access_token_alias_parses() {
122        let td = TempDir::new().unwrap();
123        let path = write_config(&td, r#"{"accessToken":"tok-2"}"#);
124        let creds = read_from(&path).unwrap();
125        assert_eq!(creds.access_token, "tok-2");
126    }
127
128    #[test]
129    fn site_and_region_default_to_the_cli_rows_and_unknown_values_fall_back() {
130        let td = TempDir::new().unwrap();
131        let path = write_config(&td, r#"{"access_token":"tok-3"}"#);
132        let creds = read_from(&path).unwrap();
133        assert_eq!(creds.site, ConsoleSite::Domestic);
134        assert_eq!(creds.region, ConsoleRegion::CnBeijing);
135
136        let path = write_config(
137            &td,
138            r#"{"access_token":"tok-3","console_site":"mars","console_region":"eu-west-1"}"#,
139        );
140        let creds = read_from(&path).unwrap();
141        assert_eq!(creds.site, ConsoleSite::Domestic);
142        assert_eq!(creds.region, ConsoleRegion::CnBeijing);
143    }
144
145    /// A missing token is the re-auth error, naming the CLI's own command.
146    #[test]
147    fn a_missing_token_names_the_fix() {
148        let td = TempDir::new().unwrap();
149        for (name, contents) in [
150            ("no-token", r#"{"console_site":"domestic"}"#),
151            ("empty-token", r#"{"access_token":"  "}"#),
152            ("not-json", "nope"),
153            ("wrong-shape", r#""just a string""#),
154        ] {
155            let path = write_config(&td, contents);
156            let err = read_from(&path).unwrap_err();
157            assert!(matches!(err, AppError::Credentials(_)), "{name}: {err:?}");
158            assert!(
159                err.to_string().contains("bl auth login --console"),
160                "{name}: {err}"
161            );
162            // Fixed strings only: nothing from the file leaks into the error.
163            assert!(!err.to_string().contains("console_site"), "{name}: {err}");
164        }
165    }
166
167    #[test]
168    fn a_missing_file_is_a_credentials_error_naming_the_path_checked() {
169        let td = TempDir::new().unwrap();
170        let missing = td.path().join("absent").join("config.json");
171        let err = read_from(&missing).unwrap_err();
172        assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
173        let message = err.to_string();
174        assert!(message.contains("bl auth login --console"), "{message}");
175        assert!(message.contains("config.json"), "{message}");
176    }
177
178    #[test]
179    fn the_probe_wants_a_non_empty_file() {
180        let td = TempDir::new().unwrap();
181        let path = td.path().join("config.json");
182        assert!(!config_present_at(&path));
183        std::fs::write(&path, "").unwrap();
184        assert!(!config_present_at(&path));
185        std::fs::write(&path, "{}").unwrap();
186        assert!(config_present_at(&path));
187        assert!(!config_present_at(td.path()));
188    }
189}