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