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/// Server-process configuration that agent commands must never inherit. A Winx
15/// shell otherwise passes the server's environment to every command, so a
16/// nested `cargo test` or `winx-code-agent` run by an agent would append
17/// synthetic events to the operator's usage log, and any command could read the
18/// HTTP bearer token. An explicit assignment in the command itself still works.
19pub const SERVER_ONLY_ENV: [&str; 4] =
20    ["WINX_USAGE_LOG", "WINX_USAGE_LOG_ROTATION", "WINX_USAGE_LOG_KEEP_DAYS", "WINX_HTTP_TOKEN"];
21
22/// How remote `Initialize(first_call)` requests select a durable shell session.
23#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, clap::ValueEnum)]
24pub enum HttpSessionAffinity {
25    /// Reuse one session per authenticated principal and canonical workspace.
26    /// This is the safe default for stateless clients whose generated thread IDs
27    /// are not stable across reconnects or conversations.
28    #[default]
29    Workspace,
30    /// Reuse one session per principal, conversation identity, and canonical
31    /// workspace. `Mcp-Session-Id` is preferred; modern stateless clients fall
32    /// back to their supplied first-call `thread_id`, then to workspace affinity.
33    /// This separates parallel conversations without giving up workspace context.
34    Conversation,
35    /// Trust the external `thread_id` as the session key. This preserves parallel
36    /// shells in one workspace, but callers must supply a stable ID and clean up
37    /// abandoned sessions themselves.
38    Thread,
39}
40
41/// Parse common human-friendly boolean spellings.
42pub fn parse_bool(value: &str) -> Option<bool> {
43    match value.trim().to_ascii_lowercase().as_str() {
44        "1" | "true" | "yes" | "on" => Some(true),
45        "0" | "false" | "no" | "off" | "" => Some(false),
46        _ => None,
47    }
48}
49
50/// Read a boolean environment variable with one consistent spelling policy.
51pub fn env_flag(name: &str) -> bool {
52    std::env::var(name).ok().as_deref().and_then(parse_bool).unwrap_or(false)
53}
54
55/// Read and trim a non-empty environment variable.
56pub fn env_text(name: &str) -> Option<String> {
57    std::env::var(name).ok().map(|value| value.trim().to_string()).filter(|value| !value.is_empty())
58}
59
60/// Default visible-content budget for one `ReadFiles` call. 48 KiB of numbered
61/// source is roughly 12-16k tokens: under Claude Code's ~25k-token tool-result
62/// cap and claude.ai's ~150k-character cap with room for the structured
63/// receipts, and below the sizes at which `ChatGPT` was observed to drop
64/// content silently. It leaves the median call (~36 KB) untouched.
65pub const DEFAULT_READ_CALL_BUDGET_BYTES: usize = 48 * 1024;
66/// Default cap for one whole-file or open-ended read. Files above ~32 KiB
67/// (~800 lines) are where an outline plus targeted ranges beats a raw dump, and
68/// the remaining 16 KiB of the call budget still serves other batch items.
69pub const DEFAULT_READ_FILE_BUDGET_BYTES: usize = 32 * 1024;
70/// Default window in which an unchanged repeat is compacted: off. Under
71/// workspace affinity a new chat inherits the thread of an earlier one, so it
72/// could be told a file is "unchanged" that it never saw. Opt in with
73/// `WINX_READ_UNCHANGED_TTL_SECS` (e.g. 900) for per-conversation threads.
74pub const DEFAULT_READ_UNCHANGED_TTL_SECS: u64 = 0;
75/// Smallest accepted non-zero byte budget; tiny budgets only produce slivers.
76pub const MIN_READ_BUDGET_BYTES: usize = 1024;
77
78/// Response-size policy for `ReadFiles`. `None` disables a byte budget.
79#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80pub struct ReadResponseBudget {
81    /// Visible file bytes per call, shared by every item in a batch.
82    pub call_bytes: Option<usize>,
83    /// Cap for one whole-file or open-ended item. Explicit `start-end` ranges
84    /// are deliberate and are bounded only by the call budget.
85    pub file_bytes: Option<usize>,
86    /// How long an unchanged, still-guarded repeat is compacted. Zero disables.
87    pub unchanged_ttl: std::time::Duration,
88}
89
90impl Default for ReadResponseBudget {
91    fn default() -> Self {
92        Self::parse(None, None, None)
93    }
94}
95
96impl ReadResponseBudget {
97    /// Read `WINX_READ_CALL_BUDGET_BYTES`, `WINX_READ_FILE_BUDGET_BYTES` and
98    /// `WINX_READ_UNCHANGED_TTL_SECS`.
99    pub fn from_env() -> Self {
100        Self::parse(
101            env_text("WINX_READ_CALL_BUDGET_BYTES").as_deref(),
102            env_text("WINX_READ_FILE_BUDGET_BYTES").as_deref(),
103            env_text("WINX_READ_UNCHANGED_TTL_SECS").as_deref(),
104        )
105    }
106
107    /// `0` disables a value; unparsable input keeps the default, and non-zero
108    /// byte budgets are raised to [`MIN_READ_BUDGET_BYTES`]. The per-file cap
109    /// never exceeds the call budget.
110    pub fn parse(call: Option<&str>, file: Option<&str>, ttl: Option<&str>) -> Self {
111        fn bytes(value: Option<&str>, default: usize) -> Option<usize> {
112            match value.map(str::trim).map(str::parse::<usize>) {
113                Some(Ok(0)) => None,
114                Some(Ok(bytes)) => Some(bytes.max(MIN_READ_BUDGET_BYTES)),
115                Some(Err(_)) | None => Some(default),
116            }
117        }
118        let call_bytes = bytes(call, DEFAULT_READ_CALL_BUDGET_BYTES);
119        let file_bytes = bytes(file, DEFAULT_READ_FILE_BUDGET_BYTES)
120            .map(|file| call_bytes.map_or(file, |call| file.min(call)));
121        let ttl_secs = ttl
122            .and_then(|value| value.trim().parse::<u64>().ok())
123            .unwrap_or(DEFAULT_READ_UNCHANGED_TTL_SECS);
124        Self { call_bytes, file_bytes, unchanged_ttl: std::time::Duration::from_secs(ttl_secs) }
125    }
126}
127
128/// Authenticated HTTP principal. Only a SHA-256 token digest is retained.
129#[derive(Clone)]
130pub struct HttpPrincipal {
131    name: String,
132    id: String,
133    token_digest: [u8; 32],
134    tool_policy: ToolPolicy,
135    weak_token: bool,
136}
137
138impl std::fmt::Debug for HttpPrincipal {
139    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        formatter
141            .debug_struct("HttpPrincipal")
142            .field("name", &self.name)
143            .field("id", &self.id)
144            .field("tool_policy", &self.tool_policy)
145            .field("token", &"[REDACTED]")
146            .finish_non_exhaustive()
147    }
148}
149
150impl HttpPrincipal {
151    pub fn new(name: impl Into<String>, token: &str, allow_weak_token: bool) -> Result<Self> {
152        Self::new_with_tool_policy(name, token, allow_weak_token, ToolPolicy::default())
153    }
154
155    pub fn new_with_tool_policy(
156        name: impl Into<String>,
157        token: &str,
158        allow_weak_token: bool,
159        tool_policy: ToolPolicy,
160    ) -> Result<Self> {
161        let name = name.into().trim().to_string();
162        if name.is_empty() {
163            return Err(WinxError::ConfigurationError(
164                "HTTP principal name cannot be empty".to_string(),
165            ));
166        }
167        if !name
168            .chars()
169            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
170        {
171            return Err(WinxError::ConfigurationError(format!(
172                "HTTP principal name {name:?} may contain only ASCII letters, digits, '_' and '-'"
173            )));
174        }
175        validate_http_token(token, allow_weak_token)?;
176        let token_digest: [u8; 32] = Sha256::digest(token.as_bytes()).into();
177        let id_digest = Sha256::digest(name.as_bytes());
178        let id = id_digest[..8].iter().fold(String::with_capacity(16), |mut output, byte| {
179            use std::fmt::Write as _;
180            let _ = write!(output, "{byte:02x}");
181            output
182        });
183        let weak_token = token.len() < MIN_HTTP_TOKEN_BYTES;
184        Ok(Self { name, id, token_digest, tool_policy, weak_token })
185    }
186
187    pub fn name(&self) -> &str {
188        &self.name
189    }
190
191    pub fn id(&self) -> &str {
192        &self.id
193    }
194
195    /// Whether this principal was admitted below the minimum token length
196    /// (`--allow-weak-token`). Such a token is guessable, so it keeps the
197    /// failed-auth lockout of its source IP.
198    pub fn has_weak_token(&self) -> bool {
199        self.weak_token
200    }
201
202    pub fn session_prefix(&self) -> String {
203        format!("p_{}__", self.id)
204    }
205
206    pub fn task_prefix(&self) -> String {
207        format!("task_{}__", self.id)
208    }
209
210    pub fn matches_token(&self, presented: &str) -> bool {
211        let digest: [u8; 32] = Sha256::digest(presented.as_bytes()).into();
212        constant_time_digest_eq(&self.token_digest, &digest)
213    }
214
215    pub fn tool_policy(&self) -> ToolPolicy {
216        self.tool_policy
217    }
218}
219
220#[derive(Debug, Deserialize)]
221#[serde(deny_unknown_fields)]
222struct PrincipalFile {
223    principals: Vec<PrincipalEntry>,
224}
225
226#[derive(Debug, Deserialize)]
227#[serde(deny_unknown_fields)]
228struct PrincipalEntry {
229    name: String,
230    token_file: Option<PathBuf>,
231    token_env: Option<String>,
232    #[serde(default)]
233    tool_profile: ToolProfile,
234    allowed_tools: Option<Vec<String>>,
235}
236
237/// Resolve either a legacy single principal or a multi-principal TOML file.
238pub fn load_http_principals(
239    principal_config: Option<&Path>,
240    command_token: Option<String>,
241    command_token_file: Option<&Path>,
242    allow_weak_token: bool,
243) -> Result<Vec<HttpPrincipal>> {
244    load_http_principals_with_policy(
245        principal_config,
246        command_token,
247        command_token_file,
248        allow_weak_token,
249        ToolPolicy::default(),
250    )
251}
252
253/// Resolve HTTP principals and apply `single_principal_policy` to legacy
254/// `--token`/`--token-file`/environment authentication.
255pub fn load_http_principals_with_policy(
256    principal_config: Option<&Path>,
257    command_token: Option<String>,
258    command_token_file: Option<&Path>,
259    allow_weak_token: bool,
260    single_principal_policy: ToolPolicy,
261) -> Result<Vec<HttpPrincipal>> {
262    if principal_config.is_some() && (command_token.is_some() || command_token_file.is_some()) {
263        return Err(WinxError::ConfigurationError(
264            "--principal-config cannot be combined with --token/--token-file".to_string(),
265        ));
266    }
267
268    let principals = if let Some(path) = principal_config {
269        let content = std::fs::read_to_string(path).map_err(|error| {
270            WinxError::ConfigurationError(format!(
271                "cannot read HTTP principal config {}: {error}",
272                path.display()
273            ))
274        })?;
275        let config: PrincipalFile = toml::from_str(&content).map_err(|error| {
276            WinxError::ConfigurationError(format!(
277                "invalid HTTP principal config {}: {error}",
278                path.display()
279            ))
280        })?;
281        if config.principals.is_empty() {
282            return Err(WinxError::ConfigurationError(
283                "HTTP principal config must contain at least one [[principals]] entry".to_string(),
284            ));
285        }
286        config
287            .principals
288            .into_iter()
289            .map(|entry| {
290                let tool_policy =
291                    ToolPolicy::resolve(entry.tool_profile, entry.allowed_tools.as_deref())?;
292                let token = match (entry.token_file.as_deref(), entry.token_env.as_deref()) {
293                    (Some(path), None) => load_secret_file(path)?,
294                    (None, Some(name)) => std::env::var(name).map_err(|error| {
295                        WinxError::ConfigurationError(format!(
296                            "HTTP principal {} token_env {name:?} is unavailable: {error}",
297                            entry.name
298                        ))
299                    })?,
300                    (Some(_), Some(_)) => {
301                        return Err(WinxError::ConfigurationError(format!(
302                            "HTTP principal {} must set exactly one of token_file or token_env",
303                            entry.name
304                        )))
305                    }
306                    (None, None) => {
307                        return Err(WinxError::ConfigurationError(format!(
308                            "HTTP principal {} must set token_file or token_env",
309                            entry.name
310                        )))
311                    }
312                };
313                HttpPrincipal::new_with_tool_policy(
314                    entry.name,
315                    token.trim(),
316                    allow_weak_token,
317                    tool_policy,
318                )
319            })
320            .collect::<Result<Vec<_>>>()?
321    } else {
322        let token = match (command_token, command_token_file) {
323            (Some(_), Some(_)) => {
324                return Err(WinxError::ConfigurationError(
325                    "provide only one of --token or --token-file".to_string(),
326                ))
327            }
328            (Some(token), None) => token,
329            (None, Some(path)) => load_secret_file(path)?,
330            (None, None) => env_text("WINX_HTTP_TOKEN").unwrap_or_default(),
331        };
332        vec![HttpPrincipal::new_with_tool_policy(
333            "default",
334            token.trim(),
335            allow_weak_token,
336            single_principal_policy,
337        )?]
338    };
339
340    reject_duplicate_principals(&principals)?;
341    Ok(principals)
342}
343
344pub fn load_secret_file(path: &Path) -> Result<String> {
345    let metadata = std::fs::symlink_metadata(path).map_err(|error| {
346        WinxError::ConfigurationError(format!(
347            "cannot inspect secret file {}: {error}",
348            path.display()
349        ))
350    })?;
351    if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
352        return Err(WinxError::ConfigurationError(format!(
353            "secret path {} must be a regular, non-symlink file",
354            path.display()
355        )));
356    }
357    #[cfg(unix)]
358    {
359        use std::os::unix::fs::PermissionsExt as _;
360        let mode = metadata.permissions().mode();
361        if mode & 0o077 != 0 {
362            return Err(WinxError::ConfigurationError(format!(
363                "secret file {} is readable or writable by group/others; use chmod 600",
364                path.display()
365            )));
366        }
367    }
368    let value = std::fs::read_to_string(path).map_err(|error| {
369        WinxError::ConfigurationError(format!(
370            "cannot read secret file {}: {error}",
371            path.display()
372        ))
373    })?;
374    let value = value.trim().to_string();
375    if value.is_empty() {
376        return Err(WinxError::ConfigurationError(format!(
377            "secret file {} is empty",
378            path.display()
379        )));
380    }
381    Ok(value)
382}
383
384fn validate_http_token(token: &str, allow_weak_token: bool) -> Result<()> {
385    if token.is_empty() {
386        return Err(WinxError::ConfigurationError(
387            "refusing to start HTTP transport without a token (RCE exposure)".to_string(),
388        ));
389    }
390    if !allow_weak_token && token.len() < MIN_HTTP_TOKEN_BYTES {
391        return Err(WinxError::ConfigurationError(format!(
392            "HTTP token is too short ({} bytes); use at least {MIN_HTTP_TOKEN_BYTES} bytes or pass --allow-weak-token for local development",
393            token.len()
394        )));
395    }
396    Ok(())
397}
398
399fn reject_duplicate_principals(principals: &[HttpPrincipal]) -> Result<()> {
400    for (index, principal) in principals.iter().enumerate() {
401        for other in principals.iter().skip(index + 1) {
402            if principal.name == other.name || principal.id == other.id {
403                return Err(WinxError::ConfigurationError(format!(
404                    "duplicate HTTP principal name {:?}",
405                    principal.name
406                )));
407            }
408            if constant_time_digest_eq(&principal.token_digest, &other.token_digest) {
409                return Err(WinxError::ConfigurationError(format!(
410                    "HTTP principals {:?} and {:?} use the same token",
411                    principal.name, other.name
412                )));
413            }
414        }
415    }
416    Ok(())
417}
418
419fn constant_time_digest_eq(left: &[u8; 32], right: &[u8; 32]) -> bool {
420    left.iter().zip(right).fold(0_u8, |difference, (left, right)| difference | (left ^ right)) == 0
421}
422
423#[cfg(test)]
424mod tests {
425    #![allow(clippy::expect_used)]
426
427    #[cfg(unix)]
428    use super::{load_http_principals, load_secret_file};
429    use super::{parse_bool, HttpPrincipal};
430
431    #[test]
432    fn boolean_parser_is_consistent() {
433        assert_eq!(parse_bool(" TRUE "), Some(true));
434        assert_eq!(parse_bool("yes"), Some(true));
435        assert_eq!(parse_bool("off"), Some(false));
436        assert_eq!(parse_bool("false"), Some(false));
437        assert_eq!(parse_bool("maybe"), None);
438    }
439
440    #[test]
441    fn principal_keeps_only_a_digest_and_matches_constant_time() {
442        let token = "0123456789abcdef0123456789abcdef";
443        let principal = HttpPrincipal::new("chatgpt", token, false).expect("valid principal");
444        assert!(principal.matches_token(token));
445        assert!(!principal.matches_token("0123456789abcdef0123456789abcdeg"));
446        assert!(!format!("{principal:?}").contains(token));
447    }
448
449    #[test]
450    fn read_budget_parsing_has_safe_defaults_bounds_and_opt_outs() {
451        use super::{ReadResponseBudget, DEFAULT_READ_CALL_BUDGET_BYTES};
452        use std::time::Duration;
453
454        let defaults = ReadResponseBudget::default();
455        assert_eq!(defaults.call_bytes, Some(DEFAULT_READ_CALL_BUDGET_BYTES));
456        assert_eq!(defaults.file_bytes, Some(32 * 1024));
457        assert_eq!(defaults.unchanged_ttl, Duration::ZERO);
458
459        let invalid = ReadResponseBudget::parse(Some("lots"), Some("-1"), Some("soon"));
460        assert_eq!(invalid, defaults);
461
462        let disabled = ReadResponseBudget::parse(Some("0"), Some("0"), Some("0"));
463        assert_eq!(disabled.call_bytes, None);
464        assert_eq!(disabled.file_bytes, None);
465        assert!(disabled.unchanged_ttl.is_zero());
466
467        // A per-file cap can never exceed the shared call budget, and tiny
468        // budgets are raised to a useful minimum.
469        let clamped = ReadResponseBudget::parse(Some("10"), Some("999999"), None);
470        assert_eq!(clamped.call_bytes, Some(1024));
471        assert_eq!(clamped.file_bytes, Some(1024));
472        let file_only = ReadResponseBudget::parse(Some("0"), Some("4096"), None);
473        assert_eq!((file_only.call_bytes, file_only.file_bytes), (None, Some(4096)));
474    }
475
476    #[cfg(unix)]
477    #[test]
478    fn secret_file_requires_user_only_permissions() {
479        use std::os::unix::fs::PermissionsExt as _;
480
481        let directory = tempfile::TempDir::new().expect("temp directory");
482        let secret = directory.path().join("token");
483        std::fs::write(&secret, "0123456789abcdef0123456789abcdef\n").expect("write token");
484        std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o644))
485            .expect("set broad permissions");
486        assert!(load_secret_file(&secret).is_err());
487
488        std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o600))
489            .expect("set private permissions");
490        assert_eq!(
491            load_secret_file(&secret).expect("private token"),
492            "0123456789abcdef0123456789abcdef"
493        );
494    }
495
496    #[cfg(unix)]
497    #[test]
498    fn principal_toml_loads_independent_file_backed_identities() {
499        use std::os::unix::fs::PermissionsExt as _;
500
501        let directory = tempfile::TempDir::new().expect("temp directory");
502        let left = directory.path().join("left-token");
503        let right = directory.path().join("right-token");
504        for (path, value) in [
505            (&left, "left-0123456789abcdef0123456789abcdef"),
506            (&right, "right-0123456789abcdef0123456789abcdef"),
507        ] {
508            std::fs::write(path, value).expect("write token");
509            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
510                .expect("private token permissions");
511        }
512        let config = directory.path().join("principals.toml");
513        std::fs::write(
514            &config,
515            format!(
516                "[[principals]]\nname = \"left\"\ntoken_file = {left:?}\ntool_profile = \"terminal\"\n\n[[principals]]\nname = \"right\"\ntoken_file = {right:?}\nallowed_tools = [\"Initialize\", \"ReadFiles\"]\n"
517            ),
518        )
519        .expect("write principal config");
520
521        let principals =
522            load_http_principals(Some(&config), None, None, false).expect("load principals");
523        assert_eq!(principals.len(), 2);
524        assert!(principals[0].matches_token("left-0123456789abcdef0123456789abcdef"));
525        assert!(principals[1].matches_token("right-0123456789abcdef0123456789abcdef"));
526        assert_ne!(principals[0].session_prefix(), principals[1].session_prefix());
527        assert_eq!(
528            principals[0].tool_policy().names().collect::<Vec<_>>(),
529            vec!["Initialize", "BashCommand", "Execution", "Agent"]
530        );
531        assert_eq!(
532            principals[1].tool_policy().names().collect::<Vec<_>>(),
533            vec!["Initialize", "ReadFiles"]
534        );
535    }
536}