Skip to main content

winx_code_agent/
config.rs

1//! Typed configuration and secret-loading helpers.
2
3use std::path::{Path, PathBuf};
4
5use serde::Deserialize;
6use sha2::{Digest, Sha256};
7
8use crate::errors::{Result, WinxError};
9
10/// Minimum accepted HTTP bearer-token length in bytes.
11pub const MIN_HTTP_TOKEN_BYTES: usize = 32;
12
13/// How remote `Initialize(first_call)` requests select a durable shell session.
14#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, clap::ValueEnum)]
15pub enum HttpSessionAffinity {
16    /// Reuse one session per authenticated principal and canonical workspace.
17    /// This is the safe default for stateless clients whose generated thread IDs
18    /// are not stable across reconnects or conversations.
19    #[default]
20    Workspace,
21    /// Trust the external `thread_id` as the session key. This preserves parallel
22    /// shells in one workspace, but callers must supply a stable ID and clean up
23    /// abandoned sessions themselves.
24    Thread,
25}
26
27/// Parse common human-friendly boolean spellings.
28pub fn parse_bool(value: &str) -> Option<bool> {
29    match value.trim().to_ascii_lowercase().as_str() {
30        "1" | "true" | "yes" | "on" => Some(true),
31        "0" | "false" | "no" | "off" | "" => Some(false),
32        _ => None,
33    }
34}
35
36/// Read a boolean environment variable with one consistent spelling policy.
37pub fn env_flag(name: &str) -> bool {
38    std::env::var(name).ok().as_deref().and_then(parse_bool).unwrap_or(false)
39}
40
41/// Read and trim a non-empty environment variable.
42pub fn env_text(name: &str) -> Option<String> {
43    std::env::var(name).ok().map(|value| value.trim().to_string()).filter(|value| !value.is_empty())
44}
45
46/// Authenticated HTTP principal. Only a SHA-256 token digest is retained.
47#[derive(Clone)]
48pub struct HttpPrincipal {
49    name: String,
50    id: String,
51    token_digest: [u8; 32],
52}
53
54impl std::fmt::Debug for HttpPrincipal {
55    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        formatter
57            .debug_struct("HttpPrincipal")
58            .field("name", &self.name)
59            .field("id", &self.id)
60            .field("token", &"[REDACTED]")
61            .finish_non_exhaustive()
62    }
63}
64
65impl HttpPrincipal {
66    pub fn new(name: impl Into<String>, token: &str, allow_weak_token: bool) -> Result<Self> {
67        let name = name.into().trim().to_string();
68        if name.is_empty() {
69            return Err(WinxError::ConfigurationError(
70                "HTTP principal name cannot be empty".to_string(),
71            ));
72        }
73        if !name
74            .chars()
75            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
76        {
77            return Err(WinxError::ConfigurationError(format!(
78                "HTTP principal name {name:?} may contain only ASCII letters, digits, '_' and '-'"
79            )));
80        }
81        validate_http_token(token, allow_weak_token)?;
82        let token_digest: [u8; 32] = Sha256::digest(token.as_bytes()).into();
83        let id_digest = Sha256::digest(name.as_bytes());
84        let id = id_digest[..8].iter().fold(String::with_capacity(16), |mut output, byte| {
85            use std::fmt::Write as _;
86            let _ = write!(output, "{byte:02x}");
87            output
88        });
89        Ok(Self { name, id, token_digest })
90    }
91
92    pub fn name(&self) -> &str {
93        &self.name
94    }
95
96    pub fn id(&self) -> &str {
97        &self.id
98    }
99
100    pub fn session_prefix(&self) -> String {
101        format!("p_{}__", self.id)
102    }
103
104    pub fn task_prefix(&self) -> String {
105        format!("task_{}__", self.id)
106    }
107
108    pub fn matches_token(&self, presented: &str) -> bool {
109        let digest: [u8; 32] = Sha256::digest(presented.as_bytes()).into();
110        constant_time_digest_eq(&self.token_digest, &digest)
111    }
112}
113
114#[derive(Debug, Deserialize)]
115#[serde(deny_unknown_fields)]
116struct PrincipalFile {
117    principals: Vec<PrincipalEntry>,
118}
119
120#[derive(Debug, Deserialize)]
121#[serde(deny_unknown_fields)]
122struct PrincipalEntry {
123    name: String,
124    token_file: Option<PathBuf>,
125    token_env: Option<String>,
126}
127
128/// Resolve either a legacy single principal or a multi-principal TOML file.
129pub fn load_http_principals(
130    principal_config: Option<&Path>,
131    command_token: Option<String>,
132    command_token_file: Option<&Path>,
133    allow_weak_token: bool,
134) -> Result<Vec<HttpPrincipal>> {
135    if principal_config.is_some() && (command_token.is_some() || command_token_file.is_some()) {
136        return Err(WinxError::ConfigurationError(
137            "--principal-config cannot be combined with --token/--token-file".to_string(),
138        ));
139    }
140
141    let principals = if let Some(path) = principal_config {
142        let content = std::fs::read_to_string(path).map_err(|error| {
143            WinxError::ConfigurationError(format!(
144                "cannot read HTTP principal config {}: {error}",
145                path.display()
146            ))
147        })?;
148        let config: PrincipalFile = toml::from_str(&content).map_err(|error| {
149            WinxError::ConfigurationError(format!(
150                "invalid HTTP principal config {}: {error}",
151                path.display()
152            ))
153        })?;
154        if config.principals.is_empty() {
155            return Err(WinxError::ConfigurationError(
156                "HTTP principal config must contain at least one [[principals]] entry".to_string(),
157            ));
158        }
159        config
160            .principals
161            .into_iter()
162            .map(|entry| {
163                let token = match (entry.token_file.as_deref(), entry.token_env.as_deref()) {
164                    (Some(path), None) => load_secret_file(path)?,
165                    (None, Some(name)) => std::env::var(name).map_err(|error| {
166                        WinxError::ConfigurationError(format!(
167                            "HTTP principal {} token_env {name:?} is unavailable: {error}",
168                            entry.name
169                        ))
170                    })?,
171                    (Some(_), Some(_)) => {
172                        return Err(WinxError::ConfigurationError(format!(
173                            "HTTP principal {} must set exactly one of token_file or token_env",
174                            entry.name
175                        )))
176                    }
177                    (None, None) => {
178                        return Err(WinxError::ConfigurationError(format!(
179                            "HTTP principal {} must set token_file or token_env",
180                            entry.name
181                        )))
182                    }
183                };
184                HttpPrincipal::new(entry.name, token.trim(), allow_weak_token)
185            })
186            .collect::<Result<Vec<_>>>()?
187    } else {
188        let token = match (command_token, command_token_file) {
189            (Some(_), Some(_)) => {
190                return Err(WinxError::ConfigurationError(
191                    "provide only one of --token or --token-file".to_string(),
192                ))
193            }
194            (Some(token), None) => token,
195            (None, Some(path)) => load_secret_file(path)?,
196            (None, None) => env_text("WINX_HTTP_TOKEN").unwrap_or_default(),
197        };
198        vec![HttpPrincipal::new("default", token.trim(), allow_weak_token)?]
199    };
200
201    reject_duplicate_principals(&principals)?;
202    Ok(principals)
203}
204
205pub fn load_secret_file(path: &Path) -> Result<String> {
206    let metadata = std::fs::symlink_metadata(path).map_err(|error| {
207        WinxError::ConfigurationError(format!(
208            "cannot inspect secret file {}: {error}",
209            path.display()
210        ))
211    })?;
212    if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
213        return Err(WinxError::ConfigurationError(format!(
214            "secret path {} must be a regular, non-symlink file",
215            path.display()
216        )));
217    }
218    #[cfg(unix)]
219    {
220        use std::os::unix::fs::PermissionsExt as _;
221        let mode = metadata.permissions().mode();
222        if mode & 0o077 != 0 {
223            return Err(WinxError::ConfigurationError(format!(
224                "secret file {} is readable or writable by group/others; use chmod 600",
225                path.display()
226            )));
227        }
228    }
229    let value = std::fs::read_to_string(path).map_err(|error| {
230        WinxError::ConfigurationError(format!(
231            "cannot read secret file {}: {error}",
232            path.display()
233        ))
234    })?;
235    let value = value.trim().to_string();
236    if value.is_empty() {
237        return Err(WinxError::ConfigurationError(format!(
238            "secret file {} is empty",
239            path.display()
240        )));
241    }
242    Ok(value)
243}
244
245fn validate_http_token(token: &str, allow_weak_token: bool) -> Result<()> {
246    if token.is_empty() {
247        return Err(WinxError::ConfigurationError(
248            "refusing to start HTTP transport without a token (RCE exposure)".to_string(),
249        ));
250    }
251    if !allow_weak_token && token.len() < MIN_HTTP_TOKEN_BYTES {
252        return Err(WinxError::ConfigurationError(format!(
253            "HTTP token is too short ({} bytes); use at least {MIN_HTTP_TOKEN_BYTES} bytes or pass --allow-weak-token for local development",
254            token.len()
255        )));
256    }
257    Ok(())
258}
259
260fn reject_duplicate_principals(principals: &[HttpPrincipal]) -> Result<()> {
261    for (index, principal) in principals.iter().enumerate() {
262        for other in principals.iter().skip(index + 1) {
263            if principal.name == other.name || principal.id == other.id {
264                return Err(WinxError::ConfigurationError(format!(
265                    "duplicate HTTP principal name {:?}",
266                    principal.name
267                )));
268            }
269            if constant_time_digest_eq(&principal.token_digest, &other.token_digest) {
270                return Err(WinxError::ConfigurationError(format!(
271                    "HTTP principals {:?} and {:?} use the same token",
272                    principal.name, other.name
273                )));
274            }
275        }
276    }
277    Ok(())
278}
279
280fn constant_time_digest_eq(left: &[u8; 32], right: &[u8; 32]) -> bool {
281    left.iter().zip(right).fold(0_u8, |difference, (left, right)| difference | (left ^ right)) == 0
282}
283
284#[cfg(test)]
285mod tests {
286    #![allow(clippy::expect_used)]
287
288    use super::{load_http_principals, load_secret_file, parse_bool, HttpPrincipal};
289
290    #[test]
291    fn boolean_parser_is_consistent() {
292        assert_eq!(parse_bool(" TRUE "), Some(true));
293        assert_eq!(parse_bool("yes"), Some(true));
294        assert_eq!(parse_bool("off"), Some(false));
295        assert_eq!(parse_bool("false"), Some(false));
296        assert_eq!(parse_bool("maybe"), None);
297    }
298
299    #[test]
300    fn principal_keeps_only_a_digest_and_matches_constant_time() {
301        let token = "0123456789abcdef0123456789abcdef";
302        let principal = HttpPrincipal::new("chatgpt", token, false).expect("valid principal");
303        assert!(principal.matches_token(token));
304        assert!(!principal.matches_token("0123456789abcdef0123456789abcdeg"));
305        assert!(!format!("{principal:?}").contains(token));
306    }
307
308    #[cfg(unix)]
309    #[test]
310    fn secret_file_requires_user_only_permissions() {
311        use std::os::unix::fs::PermissionsExt as _;
312
313        let directory = tempfile::TempDir::new().expect("temp directory");
314        let secret = directory.path().join("token");
315        std::fs::write(&secret, "0123456789abcdef0123456789abcdef\n").expect("write token");
316        std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o644))
317            .expect("set broad permissions");
318        assert!(load_secret_file(&secret).is_err());
319
320        std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o600))
321            .expect("set private permissions");
322        assert_eq!(
323            load_secret_file(&secret).expect("private token"),
324            "0123456789abcdef0123456789abcdef"
325        );
326    }
327
328    #[cfg(unix)]
329    #[test]
330    fn principal_toml_loads_independent_file_backed_identities() {
331        use std::os::unix::fs::PermissionsExt as _;
332
333        let directory = tempfile::TempDir::new().expect("temp directory");
334        let left = directory.path().join("left-token");
335        let right = directory.path().join("right-token");
336        for (path, value) in [
337            (&left, "left-0123456789abcdef0123456789abcdef"),
338            (&right, "right-0123456789abcdef0123456789abcdef"),
339        ] {
340            std::fs::write(path, value).expect("write token");
341            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
342                .expect("private token permissions");
343        }
344        let config = directory.path().join("principals.toml");
345        std::fs::write(
346            &config,
347            format!(
348                "[[principals]]\nname = \"left\"\ntoken_file = {left:?}\n\n[[principals]]\nname = \"right\"\ntoken_file = {right:?}\n"
349            ),
350        )
351        .expect("write principal config");
352
353        let principals =
354            load_http_principals(Some(&config), None, None, false).expect("load principals");
355        assert_eq!(principals.len(), 2);
356        assert!(principals[0].matches_token("left-0123456789abcdef0123456789abcdef"));
357        assert!(principals[1].matches_token("right-0123456789abcdef0123456789abcdef"));
358        assert_ne!(principals[0].session_prefix(), principals[1].session_prefix());
359    }
360}