Skip to main content

recursive/
config_file.rs

1//! Config file support: ~/.recursive/config.toml
2//!
3//! Priority chain: CLI flag > env var > config file > hardcoded default.
4//! The config file is optional — if absent, we gracefully fall back.
5
6use crate::error::{Error, Result};
7use crate::permissions::PermissionMode;
8use crate::permissions::{LayeredPermissionsConfig, PermissionLayer, RuleSource};
9use serde::Deserialize;
10use std::path::{Path, PathBuf};
11
12/// Return the path to the global config file: ~/.recursive/config.toml.
13/// Returns None if the home directory cannot be determined.
14///
15/// Honours `RECURSIVE_HOME` for tests (matching `paths::user_data_dir`)
16/// before falling back to `dirs::home_dir()`. Without the
17/// `RECURSIVE_HOME` short-circuit, tests that pin HOME via
18/// `PinnedHome` still race with tests that mutate `HOME` directly
19/// (without holding the env lock) on macOS, where `dirs::home_dir`
20/// can re-resolve through `getpwuid_r` mid-test.
21pub fn config_file_path() -> Option<PathBuf> {
22    if let Some(custom) = std::env::var_os("RECURSIVE_HOME") {
23        return Some(PathBuf::from(custom).join(".recursive").join("config.toml"));
24    }
25    dirs::home_dir().map(|h| h.join(".recursive").join("config.toml"))
26}
27
28/// Top-level deserialized structure of config.toml.
29#[derive(Debug, Default, Deserialize)]
30pub struct FileConfig {
31    pub provider: Option<ProviderSection>,
32    pub agent: Option<AgentSection>,
33    /// Optional `[permissions]` section. When present, restricts which
34    /// tools the agent may invoke. Schema mirrors
35    /// [`crate::permissions::PermissionsConfig`]. See g140.
36    pub permissions: Option<PermissionsSection>,
37    /// Optional `[sandbox]` section. Expands the filesystem sandbox beyond
38    /// the primary workspace so the agent can read (and, if declared,
39    /// write) files in additional directories.
40    #[serde(default)]
41    pub sandbox: Option<SandboxSection>,
42    /// Optional `[search]` section. Web search provider configuration.
43    /// Falls back to `RECURSIVE_WEB_SEARCH_*` env vars.
44    #[serde(default)]
45    pub search: Option<SearchSection>,
46    /// Optional `[stuck]` section. Stuck detection thresholds.
47    /// Falls back to `RECURSIVE_STUCK_*` env vars.
48    #[serde(default)]
49    pub stuck: Option<StuckSection>,
50    /// Optional `[limits]` section. Runtime limits.
51    /// Falls back to `RECURSIVE_*` env vars.
52    #[serde(default)]
53    pub limits: Option<LimitsSection>,
54}
55
56/// [provider] section.
57#[derive(Debug, Deserialize)]
58pub struct ProviderSection {
59    #[serde(rename = "type")]
60    pub provider_type: Option<String>,
61    pub api_key: Option<String>,
62    pub api_base: Option<String>,
63    pub model: Option<String>,
64    /// Preset id from the bundled `providers.toml` (e.g. `"deepseek"`).
65    /// When set, `Config::from_env` uses it as the base for `type` / `api_base` /
66    /// `model` / `api_key`; explicit fields above still win. Resolved at load
67    /// time — not persisted back via `set_value`.
68    #[serde(default)]
69    pub preset: Option<String>,
70}
71
72/// [agent] section.
73#[derive(Debug, Deserialize)]
74pub struct AgentSection {
75    pub max_steps: Option<usize>,
76    /// Optional `max_tokens` override (max output tokens per response).
77    /// Second-tier fallback for `RECURSIVE_MAX_TOKENS` (env var wins; the
78    /// active provider preset's ModelSpec.max_tokens is consulted first).
79    #[serde(default)]
80    pub max_tokens: Option<u32>,
81    pub temperature: Option<f64>,
82    pub shell_timeout_secs: Option<u64>,
83}
84
85/// `[sandbox]` section. Lists additional directories the agent may reach
86/// beyond the primary workspace.
87///
88/// `extra_dirs` are read-write roots (the agent can `Read`, `Write`, `Edit`
89/// inside them). `extra_readonly_dirs` are read-only roots (the agent can
90/// `Read` / `Glob` / `Grep` but not `Write` / `Edit`). Paths may be absolute
91/// or relative to the current working directory at agent start.
92///
93/// Example:
94/// ```toml
95/// [sandbox]
96/// extra_dirs = ["/opt/shared-writable"]
97/// extra_readonly_dirs = ["/etc/project-ref", "../neighbour-repo"]
98/// ```
99#[derive(Debug, Default, Deserialize, Clone)]
100pub struct SandboxSection {
101    #[serde(default)]
102    pub extra_dirs: Vec<String>,
103    #[serde(default)]
104    pub extra_readonly_dirs: Vec<String>,
105}
106
107/// [permissions] section. Wire-compatible with
108/// [`crate::permissions::PermissionsConfig`] but lives here so config
109/// loading does not couple to that crate.
110/// `[search]` section. Web search provider configuration.
111///
112/// Falls back to `RECURSIVE_WEB_SEARCH_PROVIDER`, `RECURSIVE_WEB_SEARCH_API_KEY`,
113/// and `RECURSIVE_WEB_SEARCH_JINA_KEY` env vars respectively.
114///
115/// Example:
116/// ```toml
117/// [search]
118/// provider = "brave"
119/// api_key = "BSA..."
120/// jina_key = "jina_..."
121/// ```
122#[derive(Debug, Default, Deserialize, Clone)]
123pub struct SearchSection {
124    /// Search provider name: brave, tavily, serper, bocha, bing.
125    /// Corresponding env var: `RECURSIVE_WEB_SEARCH_PROVIDER`.
126    pub provider: Option<String>,
127    /// API key for the chosen search provider.
128    /// Corresponding env var: `RECURSIVE_WEB_SEARCH_API_KEY`.
129    /// ⚠️  Like provider.api_key, storing search keys in config.toml
130    /// means an agent with `run_shell` can `cat` them out. Consider
131    /// using the env var or `set-secret` instead.
132    pub api_key: Option<String>,
133    /// Optional Jina AI Search API key for higher quota.
134    /// Corresponding env var: `RECURSIVE_WEB_SEARCH_JINA_KEY`.
135    pub jina_key: Option<String>,
136}
137
138/// `[stuck]` section. Stuck detection thresholds.
139///
140/// Falls back to `RECURSIVE_STUCK_WINDOW` and `RECURSIVE_STUCK_ERROR_RATE`
141/// env vars respectively.
142///
143/// Example:
144/// ```toml
145/// [stuck]
146/// window = 10
147/// error_rate = 0.8
148/// ```
149#[derive(Debug, Default, Deserialize, Clone)]
150pub struct StuckSection {
151    /// Number of recent steps to check for stuck detection.
152    /// Corresponding env var: `RECURSIVE_STUCK_WINDOW`.
153    pub window: Option<usize>,
154    /// Fraction of steps in the window that must be errors to declare
155    /// the agent "stuck". Corresponding env var: `RECURSIVE_STUCK_ERROR_RATE`.
156    pub error_rate: Option<f64>,
157}
158
159/// `[limits]` section. Various runtime limits and thresholds.
160///
161/// Falls back to individual `RECURSIVE_*` env vars.
162///
163/// Example:
164/// ```toml
165/// [limits]
166/// max_search_rounds = 5
167/// subagent_max_depth = 3
168/// max_concurrent_runs = 8
169/// goal_eval_transcript_tail = 12
170/// ```
171#[derive(Debug, Default, Deserialize, Clone)]
172pub struct LimitsSection {
173    /// Maximum number of ToolSearchTool round-trips per
174    /// `complete_with_search` / `stream_with_search` call.
175    /// Corresponding env var: `RECURSIVE_MAX_SEARCH_ROUNDS`. Default: 3.
176    pub max_search_rounds: Option<usize>,
177    /// Maximum nesting depth for sub-agents and parallel workers.
178    /// Corresponding env var: `RECURSIVE_SUBAGENT_MAX_DEPTH`. Default: 2.
179    pub subagent_max_depth: Option<usize>,
180    /// Maximum number of concurrent agent runs across all HTTP endpoints.
181    /// Corresponding env var: `RECURSIVE_MAX_CONCURRENT_RUNS`. Default: 8.
182    pub max_concurrent_runs: Option<usize>,
183    /// Number of most-recent transcript messages passed to the goal
184    /// evaluator judge on each turn.
185    /// Corresponding env var: `RECURSIVE_GOAL_EVAL_TRANSCRIPT_TAIL`. Default: 12.
186    pub goal_eval_transcript_tail: Option<usize>,
187}
188
189#[derive(Debug, Default, Deserialize, Clone)]
190pub struct PermissionsSection {
191    #[serde(default)]
192    pub allow: Vec<String>,
193    #[serde(default)]
194    pub deny: Vec<String>,
195    #[serde(default)]
196    pub interactive: Vec<String>,
197    /// Tools that require plan mode before use.
198    #[serde(default)]
199    pub plan: Vec<String>,
200    /// Default permission mode. Accepts both old and new format names.
201    /// New: "default", "acceptEdits", "bypassPermissions", "dontAsk", "plan".
202    /// Old: "allow" (→ default), "deny" (→ dontAsk), "interactive" (→ dontAsk).
203    /// The "plan" variant can also be an object `{prePlanMode, bypassAvailable}`.
204    #[serde(default)]
205    pub mode: Option<PermissionMode>,
206}
207
208impl FileConfig {
209    /// Load from the default path (~/.recursive/config.toml).
210    /// Returns Ok(None) if the file doesn't exist.
211    /// Returns Err if the file exists but is malformed.
212    pub fn load() -> Result<Option<Self>> {
213        let path = match config_file_path() {
214            Some(p) => p,
215            None => return Ok(None),
216        };
217        Self::load_from(&path)
218    }
219
220    /// Load from an explicit path.
221    pub fn load_from(path: &Path) -> Result<Option<Self>> {
222        if !path.exists() {
223            return Ok(None);
224        }
225        let content = std::fs::read_to_string(path).map_err(Error::Io)?;
226        let config: FileConfig = toml::from_str(&content).map_err(|e| Error::Config {
227            message: format!("failed to parse config file {}: {}", path.display(), e),
228        })?;
229        Ok(Some(config))
230    }
231}
232
233/// Write a dotted key=value to ~/.recursive/config.toml.
234/// Supports dotted keys like "provider.model", "agent.max_steps".
235/// Creates the file and parent directory if needed.
236pub fn set_value(key: &str, value: &str) -> Result<()> {
237    // L1 (Goal 267 follow-up): refuse to persist provider.api_key (or any
238    // dotted variant) to disk. The init wizard and `recursive config
239    // set-secret` route secrets through [`set_secret`], which writes a
240    // 0600 shell-sourceable env file the binary never reads. A key
241    // never written to ~/.recursive/config.toml cannot be exfiltrated by
242    // an agent with a `run_shell` tool that cats the config file (this
243    // is the leak class that produced the .dev/journal key disclosure
244    // in run-20260602T090748Z-34743.md).
245    if key == "provider.api_key" || key.starts_with("provider.api_key.") {
246        return Err(Error::Config {
247            message: "refusing to persist provider.api_key to ~/.recursive/config.toml.\n\
248                      \n\
249                      The binary reads API keys from the process env at runtime,\n\
250                      never from the config file. Use one of:\n\
251                      \n  \
252                      • export DEEPSEEK_API_KEY='<key>'  in your shell rc, or\n  \
253                      • `recursive config set-secret <ENV_NAME> <KEY>` to write\n    \
254                      a 0600 shell-sourceable file at ~/.recursive/secrets.env\n\
255                      \n\
256                      (set the env var, or `source ~/.recursive/secrets.env`\n\
257                      from your shell rc, before running `recursive`.)"
258                .into(),
259        });
260    }
261
262    let path = config_file_path().ok_or_else(|| Error::Config {
263        message: "cannot determine home directory".into(),
264    })?;
265
266    // Ensure directory exists
267    if let Some(parent) = path.parent() {
268        std::fs::create_dir_all(parent).map_err(Error::Io)?;
269    }
270
271    // Read existing or start fresh
272    let content = if path.exists() {
273        std::fs::read_to_string(&path).map_err(Error::Io)?
274    } else {
275        String::new()
276    };
277
278    let mut doc: toml::Table = content.parse::<toml::Table>().unwrap_or_default();
279
280    // Parse dotted key "provider.model" → table["provider"]["model"]
281    let parts: Vec<&str> = key.splitn(2, '.').collect();
282    match parts.as_slice() {
283        [section, field] => {
284            let table = doc
285                .entry(*section)
286                .or_insert_with(|| toml::Value::Table(toml::Table::new()));
287            if let toml::Value::Table(t) = table {
288                t.insert(field.to_string(), smart_value(value));
289            }
290        }
291        [field] => {
292            doc.insert(field.to_string(), smart_value(value));
293        }
294        _ => {
295            return Err(Error::Config {
296                message: format!("invalid key format: {key}"),
297            })
298        }
299    }
300
301    let toml_str = toml::to_string_pretty(&doc).map_err(|e| Error::Config {
302        message: format!("failed to serialize config: {}", e),
303    })?;
304    std::fs::write(&path, toml_str).map_err(Error::Io)?;
305    Ok(())
306}
307
308/// Path of the shell-sourceable secrets file. Mirrors the home-dir
309/// resolution in [`config_file_path`] (honours `RECURSIVE_HOME` for tests).
310pub fn secrets_env_path() -> Option<PathBuf> {
311    if let Some(custom) = std::env::var_os("RECURSIVE_HOME") {
312        return Some(PathBuf::from(custom).join(".recursive").join("secrets.env"));
313    }
314    dirs::home_dir().map(|h| h.join(".recursive").join("secrets.env"))
315}
316
317/// Persist a secret (typically an API key) to a 0600 shell-sourceable
318/// env file. The binary does NOT read this file at runtime; the user is
319/// expected to `source` it from their shell rc so the secret is in the
320/// process env when `recursive` runs.
321///
322/// This is the L1 half of the .dev/journal key-leak fix: a key that
323/// never lands in `~/.recursive/config.toml` cannot be `cat`'d by an
324/// agent's `run_shell` tool and end up in a tracked journal.
325pub fn set_secret(env_name: &str, value: &str) -> Result<()> {
326    let path = secrets_env_path().ok_or_else(|| Error::Config {
327        message: "cannot determine home directory".into(),
328    })?;
329
330    if let Some(parent) = path.parent() {
331        std::fs::create_dir_all(parent).map_err(Error::Io)?;
332    }
333
334    // Read existing lines (preserve any other env vars the user previously
335    // set via the same file). Idempotent: re-running with the same
336    // (env_name, value) yields the same file.
337    let existing = std::fs::read_to_string(&path).unwrap_or_default();
338    let prefix = format!("export {env_name}=");
339    let new_line = format!("{prefix}'{}'", shell_single_quote(value));
340
341    let mut lines: Vec<String> = existing.lines().map(String::from).collect();
342    let mut found = false;
343    for line in lines.iter_mut() {
344        if line.trim_start().starts_with(&prefix) {
345            *line = new_line.clone();
346            found = true;
347            break;
348        }
349    }
350    if !found {
351        if !existing.is_empty() && !existing.ends_with('\n') {
352            // Existing file has no trailing newline; add one before appending.
353            lines.push(String::new());
354        }
355        lines.push(new_line);
356    }
357
358    let mut content = String::new();
359    if existing.is_empty() {
360        content.push_str(
361            "# Generated by `recursive init` / `recursive config set-secret`.\n\
362             # Source this from your shell rc to load API keys into the env:\n\
363             #   source ~/.recursive/secrets.env\n\
364             # File mode is 0600 (owner read/write only).\n",
365        );
366    }
367    content.push_str(&lines.join("\n"));
368    content.push('\n');
369
370    std::fs::write(&path, content).map_err(Error::Io)?;
371
372    // Restrict permissions to owner. On Windows the chmod is a no-op, but
373    // the file is still per-user in the user's profile directory.
374    #[cfg(unix)]
375    {
376        use std::os::unix::fs::PermissionsExt;
377        let perms = std::fs::Permissions::from_mode(0o600);
378        std::fs::set_permissions(&path, perms).map_err(Error::Io)?;
379    }
380
381    Ok(())
382}
383
384/// Shell-escape a value for inclusion in a single-quoted `export FOO='…'`
385/// line. Replaces `'` with the standard `'\''` close-quote / escape /
386/// reopen-quote sequence.
387fn shell_single_quote(s: &str) -> String {
388    s.replace('\'', "'\\''")
389}
390
391/// Convert a string to the appropriate TOML value type.
392fn smart_value(s: &str) -> toml::Value {
393    if let Ok(i) = s.parse::<i64>() {
394        toml::Value::Integer(i)
395    } else if let Ok(f) = s.parse::<f64>() {
396        toml::Value::Float(f)
397    } else if s == "true" || s == "false" {
398        toml::Value::Boolean(s == "true")
399    } else {
400        toml::Value::String(s.to_string())
401    }
402}
403
404/// Load layered permissions from user config and project config.
405///
406/// Resolution order (highest priority first):
407/// 1. Session layer (empty, filled at runtime via Goal 196)
408/// 2. Project layer (`.recursive/config.toml` in the workspace)
409/// 3. User layer (`~/.recursive/config.toml`)
410///
411/// The Session layer is always present (even if empty) so that runtime
412/// rule updates can be written to it.
413pub fn load_layered_permissions(workspace: &Path) -> LayeredPermissionsConfig {
414    let mut config = LayeredPermissionsConfig::default();
415
416    // User layer (lowest priority). Resolve the home dir via the same
417    // RECURSIVE_HOME-aware helper used elsewhere in this module, so the path
418    // is consistent with `config_file_path()` and correct on Windows (where
419    // HOME is rarely set; dirs::home_dir reads USERPROFILE).
420    let user_config = std::env::var_os("RECURSIVE_HOME")
421        .map(|custom| PathBuf::from(custom).join(".recursive").join("config.toml"))
422        .or_else(|| dirs::home_dir().map(|h| h.join(".recursive").join("config.toml")));
423    if let Some(path) = user_config {
424        if let Some(layer) = load_permission_layer(&path, RuleSource::User) {
425            config.layers.push(layer);
426        }
427    }
428
429    // Project layer (medium priority)
430    let project_path = workspace.join(".recursive").join("config.toml");
431    if let Some(layer) = load_permission_layer(&project_path, RuleSource::Project) {
432        config.layers.push(layer);
433    }
434
435    // Session layer (highest priority) — always present, empty by default
436    config.layers.push(PermissionLayer {
437        source: RuleSource::Session,
438        ..Default::default()
439    });
440
441    config
442}
443
444/// Load a single permission layer from a TOML file, if it exists.
445///
446/// Returns `None` if the file doesn't exist or can't be parsed.
447fn load_permission_layer(path: &Path, source: RuleSource) -> Option<PermissionLayer> {
448    if !path.exists() {
449        return None;
450    }
451    let content = std::fs::read_to_string(path).ok()?;
452    // Parse as FileConfig first so we only extract the [permissions] section.
453    // This avoids picking up unrelated sections (e.g. [provider]) as empty defaults.
454    let file_config: FileConfig = toml::from_str(&content).ok()?;
455    let section = file_config.permissions?;
456    Some(PermissionLayer {
457        source,
458        allow: section.allow,
459        deny: section.deny,
460        interactive: section.interactive,
461    })
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn load_returns_none_when_missing() {
470        let result = FileConfig::load_from(Path::new("/nonexistent/path.toml")).unwrap();
471        assert!(result.is_none());
472    }
473
474    #[test]
475    fn load_parses_valid_toml() {
476        let tmp = tempfile::NamedTempFile::new().unwrap();
477        std::fs::write(
478            tmp.path(),
479            r#"
480[provider]
481type = "openai"
482api_key = "sk-test"
483api_base = "https://api.deepseek.com"
484model = "deepseek-chat"
485
486[agent]
487max_steps = 64
488temperature = 0.5
489"#,
490        )
491        .unwrap();
492
493        let config = FileConfig::load_from(tmp.path()).unwrap();
494        assert!(config.is_some());
495        let c = config.unwrap();
496        let p = c.provider.unwrap();
497        assert_eq!(p.provider_type.as_deref(), Some("openai"));
498        assert_eq!(p.api_key.as_deref(), Some("sk-test"));
499        assert_eq!(p.api_base.as_deref(), Some("https://api.deepseek.com"));
500        assert_eq!(p.model.as_deref(), Some("deepseek-chat"));
501        let a = c.agent.unwrap();
502        assert_eq!(a.max_steps, Some(64));
503        assert_eq!(a.temperature, Some(0.5));
504    }
505
506    #[test]
507    fn load_errors_on_malformed() {
508        let tmp = tempfile::NamedTempFile::new().unwrap();
509        std::fs::write(tmp.path(), "this is [[[not valid toml").unwrap();
510        let result = FileConfig::load_from(tmp.path());
511        assert!(result.is_err());
512    }
513
514    #[test]
515    fn smart_value_parses_types() {
516        assert_eq!(smart_value("42"), toml::Value::Integer(42));
517        assert_eq!(smart_value("0.5"), toml::Value::Float(0.5));
518        assert_eq!(smart_value("true"), toml::Value::Boolean(true));
519        assert_eq!(smart_value("hello"), toml::Value::String("hello".into()));
520    }
521
522    #[test]
523    fn parse_provider_section_with_preset() {
524        let tmp = tempfile::NamedTempFile::new().unwrap();
525        std::fs::write(
526            tmp.path(),
527            r#"
528[provider]
529preset = "deepseek"
530"#,
531        )
532        .unwrap();
533
534        let config = FileConfig::load_from(tmp.path()).unwrap().unwrap();
535        let p = config.provider.unwrap();
536        assert_eq!(p.preset.as_deref(), Some("deepseek"));
537        // Other fields are absent — preset resolution happens in Config::from_env.
538        assert!(p.provider_type.is_none());
539        assert!(p.api_base.is_none());
540        assert!(p.model.is_none());
541        assert!(p.api_key.is_none());
542    }
543
544    #[test]
545    fn parse_search_stuck_limits_sections() {
546        let tmp = tempfile::NamedTempFile::new().unwrap();
547        std::fs::write(
548            tmp.path(),
549            r#"
550[search]
551provider = "brave"
552api_key = "BSA-test-key"
553jina_key = "jina-test-key"
554
555[stuck]
556window = 15
557error_rate = 0.7
558
559[limits]
560max_search_rounds = 5
561subagent_max_depth = 3
562max_concurrent_runs = 16
563goal_eval_transcript_tail = 20
564"#,
565        )
566        .unwrap();
567
568        let config = FileConfig::load_from(tmp.path()).unwrap().unwrap();
569
570        // [search]
571        let s = config.search.unwrap();
572        assert_eq!(s.provider.as_deref(), Some("brave"));
573        assert_eq!(s.api_key.as_deref(), Some("BSA-test-key"));
574        assert_eq!(s.jina_key.as_deref(), Some("jina-test-key"));
575
576        // [stuck]
577        let st = config.stuck.unwrap();
578        assert_eq!(st.window, Some(15));
579        assert_eq!(st.error_rate, Some(0.7));
580
581        // [limits]
582        let l = config.limits.unwrap();
583        assert_eq!(l.max_search_rounds, Some(5));
584        assert_eq!(l.subagent_max_depth, Some(3));
585        assert_eq!(l.max_concurrent_runs, Some(16));
586        assert_eq!(l.goal_eval_transcript_tail, Some(20));
587    }
588
589    #[test]
590    fn search_stuck_limits_are_optional() {
591        // A config with only [provider] must not error on missing new sections.
592        let tmp = tempfile::NamedTempFile::new().unwrap();
593        std::fs::write(
594            tmp.path(),
595            r#"
596[provider]
597model = "test-model"
598"#,
599        )
600        .unwrap();
601
602        let config = FileConfig::load_from(tmp.path()).unwrap().unwrap();
603        assert!(config.search.is_none());
604        assert!(config.stuck.is_none());
605        assert!(config.limits.is_none());
606    }
607
608    #[test]
609    fn set_value_preset_round_trips() {
610        // The dotted-key writer must handle the new `provider.preset` field.
611        let tmp = tempfile::tempdir().unwrap();
612        let path = tmp.path().join("config.toml");
613        std::fs::create_dir_all(tmp.path()).unwrap();
614        std::fs::write(&path, "[provider]\nmodel = \"x\"\n").unwrap();
615
616        // Manually invoke the same dotted-key logic the public set_value uses,
617        // since set_value writes to ~/.recursive/config.toml via HOME.
618        let content = std::fs::read_to_string(&path).unwrap();
619        let mut doc: toml::Table = content.parse().unwrap();
620        let parts: Vec<&str> = "provider.preset".splitn(2, '.').collect();
621        if let [section, field] = parts.as_slice() {
622            let table = doc
623                .entry(*section)
624                .or_insert_with(|| toml::Value::Table(toml::Table::new()));
625            if let toml::Value::Table(t) = table {
626                t.insert(field.to_string(), smart_value("anthropic"));
627            }
628        }
629        std::fs::write(&path, toml::to_string_pretty(&doc).unwrap()).unwrap();
630
631        let loaded = FileConfig::load_from(&path).unwrap().unwrap();
632        assert_eq!(
633            loaded.provider.unwrap().preset.as_deref(),
634            Some("anthropic")
635        );
636    }
637
638    #[test]
639    fn set_value_creates_file_and_writes() {
640        let tmp = tempfile::tempdir().unwrap();
641        let path = tmp.path().join("config.toml");
642
643        // Temporarily override the path resolution by writing directly
644        std::fs::create_dir_all(tmp.path()).unwrap();
645        // We'll test the write logic manually since config_file_path() uses HOME
646        let content = String::new();
647        let mut doc: toml::Table = content.parse::<toml::Table>().unwrap_or_default();
648
649        let parts: Vec<&str> = "provider.model".splitn(2, '.').collect();
650        if let [section, field] = parts.as_slice() {
651            let table = doc
652                .entry(*section)
653                .or_insert_with(|| toml::Value::Table(toml::Table::new()));
654            if let toml::Value::Table(t) = table {
655                t.insert(field.to_string(), smart_value("deepseek-chat"));
656            }
657        }
658
659        let output = toml::to_string_pretty(&doc).unwrap();
660        std::fs::write(&path, &output).unwrap();
661
662        // Verify
663        let loaded = FileConfig::load_from(&path).unwrap().unwrap();
664        assert_eq!(
665            loaded.provider.unwrap().model.as_deref(),
666            Some("deepseek-chat")
667        );
668    }
669
670    #[test]
671    fn test_load_layered_permissions_session_layer_always_present() {
672        let tmp = tempfile::tempdir().unwrap();
673        let workspace = tmp.path().join("workspace");
674        std::fs::create_dir_all(&workspace).unwrap();
675        let fake_home = tmp.path().join("home");
676        std::fs::create_dir_all(&fake_home).unwrap();
677
678        // Pin HOME (under env_lock) so a concurrent test can't interleave
679        // its own HOME mutation and have its User layer leak into the
680        // "exactly one Session layer" assertion.
681        let _pin = crate::test_util::PinnedHome::new(&fake_home);
682        let config = load_layered_permissions(&workspace);
683
684        // Session layer is always present
685        assert!(config
686            .layers
687            .iter()
688            .any(|l| l.source == RuleSource::Session));
689        // Even with no config files, we get only the session layer
690        assert_eq!(config.layers.len(), 1);
691        assert_eq!(config.layers[0].source, RuleSource::Session);
692    }
693
694    #[test]
695    fn test_load_layered_permissions_loads_user_and_project() {
696        let tmp = tempfile::tempdir().unwrap();
697        let home = tmp.path().join("home");
698        let project = tmp.path().join("project");
699
700        // Create user config
701        std::fs::create_dir_all(home.join(".recursive")).unwrap();
702        std::fs::write(
703            home.join(".recursive").join("config.toml"),
704            r#"
705[permissions]
706allow = ["Read"]
707deny = ["Bash"]
708"#,
709        )
710        .unwrap();
711
712        // Create project config
713        std::fs::create_dir_all(project.join(".recursive")).unwrap();
714        std::fs::write(
715            project.join(".recursive").join("config.toml"),
716            r#"
717[permissions]
718allow = ["Write"]
719interactive = ["delete_file"]
720"#,
721        )
722        .unwrap();
723
724        // Override home dir for the test. RECURSIVE_HOME is the cross-platform
725        // override honoured by load_layered_permissions; setting HOME directly
726        // does not work on Windows (dirs::home_dir reads USERPROFILE, not HOME).
727        // PinnedRecursiveHome holds the env lock so parallel tests are not
728        // polluted while the pin is in effect.
729        let _pin = crate::test_util::PinnedRecursiveHome::new(&home);
730
731        let config = load_layered_permissions(&project);
732
733        // Should have 3 layers: User, Project, Session
734        assert_eq!(config.layers.len(), 3);
735        assert_eq!(config.layers[0].source, RuleSource::User);
736        assert_eq!(config.layers[1].source, RuleSource::Project);
737        assert_eq!(config.layers[2].source, RuleSource::Session);
738
739        // User layer has allow/deny
740        assert_eq!(config.layers[0].allow, vec!["Read"]);
741        assert_eq!(config.layers[0].deny, vec!["Bash"]);
742
743        // Project layer has allow/interactive
744        assert_eq!(config.layers[1].allow, vec!["Write"]);
745        assert_eq!(config.layers[1].interactive, vec!["delete_file"]);
746    }
747
748    // ---- L1: provider.api_key must NEVER land in ~/.recursive/config.toml ----
749    //
750    // See set_value() and set_secret() doc comments for the threat model.
751    // The agent's `run_shell` tool can `cat` any file the binary can read,
752    // so a key on disk in config.toml can be exfiltrated into a tracked
753    // journal. The fix is to refuse the write entirely and route the
754    // secret through set_secret() to a 0600 file the binary never reads.
755
756    #[test]
757    fn set_value_refuses_provider_api_key() {
758        let tmp = tempfile::tempdir().unwrap();
759        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
760
761        let err = set_value(
762            "provider.api_key",
763            "sk-fixture-aaaaaaaaaaaaaaaaaaaaaaaaaaaa",
764        )
765        .expect_err("set_value must refuse provider.api_key");
766        let msg = format!("{err}");
767        assert!(
768            msg.contains("refusing to persist"),
769            "error should mention refusal; got: {msg}"
770        );
771        assert!(
772            msg.contains("DEEPSEEK_API_KEY") || msg.contains("set-secret"),
773            "error should steer the user to env var or set-secret; got: {msg}"
774        );
775
776        // Belt: the config file must not exist on disk at all.
777        let path = config_file_path().unwrap();
778        assert!(
779            !path.exists(),
780            "config.toml must not be created when refusing the write (path={})",
781            path.display()
782        );
783    }
784
785    #[test]
786    fn set_value_refuses_provider_api_key_dotted_subkey() {
787        let tmp = tempfile::tempdir().unwrap();
788        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
789
790        let err = set_value("provider.api_key.something", "sk-abc")
791            .expect_err("set_value must refuse dotted variants of provider.api_key");
792        let msg = format!("{err}");
793        assert!(msg.contains("refusing to persist"), "got: {msg}");
794    }
795
796    #[test]
797    fn set_value_allows_non_secret_provider_keys() {
798        // Regression guard: only provider.api_key is refused, not
799        // provider.model, provider.api_base, provider.preset, etc.
800        let tmp = tempfile::tempdir().unwrap();
801        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
802
803        set_value("provider.model", "deepseek-chat").expect("model must write");
804        set_value("provider.api_base", "https://api.deepseek.com").expect("api_base must write");
805        set_value("provider.preset", "deepseek").expect("preset must write");
806
807        let path = config_file_path().unwrap();
808        let content = std::fs::read_to_string(&path).unwrap();
809        assert!(content.contains("deepseek-chat"));
810        assert!(content.contains("https://api.deepseek.com"));
811        assert!(content.contains("deepseek"));
812        // Belt: no api_key line.
813        assert!(
814            !content.contains("api_key"),
815            "no api_key line should be present, got:\n{content}"
816        );
817    }
818
819    #[test]
820    fn set_secret_writes_to_secrets_env_with_0600_perms() {
821        let tmp = tempfile::tempdir().unwrap();
822        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
823
824        set_secret(
825            "DEEPSEEK_API_KEY",
826            "sk-fixture-aaaaaaaaaaaaaaaaaaaaaaaaaaaa",
827        )
828        .expect("set_secret should succeed");
829
830        let path = secrets_env_path().unwrap();
831        let content = std::fs::read_to_string(&path).unwrap();
832        assert!(
833            content.contains("export DEEPSEEK_API_KEY='sk-fixture-aaaaaaaaaaaaaaaaaaaaaaaaaaaa'"),
834            "expected shell-sourceable line, got:\n{content}"
835        );
836
837        // 0600 on unix; on Windows, the chmod is a no-op and the file is
838        // already in the per-user profile directory, so the test only
839        // enforces the perms on unix.
840        #[cfg(unix)]
841        {
842            use std::os::unix::fs::PermissionsExt;
843            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
844            assert_eq!(mode, 0o600, "expected 0600 perms, got {mode:o}");
845        }
846
847        // Belt: ~/.recursive/config.toml must NOT have been created.
848        let cfg_path = config_file_path().unwrap();
849        assert!(
850            !cfg_path.exists(),
851            "config.toml must not be created as a side effect of set_secret"
852        );
853    }
854
855    #[test]
856    fn set_secret_updates_existing_line_in_place() {
857        let tmp = tempfile::tempdir().unwrap();
858        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
859
860        set_secret(
861            "DEEPSEEK_API_KEY",
862            "sk-old-key-aaaaaaaaaaaaaaaaaaaaaaaaaaaa",
863        )
864        .unwrap();
865        set_secret(
866            "DEEPSEEK_API_KEY",
867            "sk-new-key-bbbbbbbbbbbbbbbbbbbbbbbbbbbb",
868        )
869        .unwrap();
870
871        let content = std::fs::read_to_string(secrets_env_path().unwrap()).unwrap();
872        assert!(
873            !content.contains("sk-old-key"),
874            "old value must be replaced, not duplicated; got:\n{content}"
875        );
876        assert!(
877            content.contains("sk-new-key"),
878            "new value must be present; got:\n{content}"
879        );
880        // Exactly one DEEPSEEK_API_KEY line.
881        let count = content
882            .lines()
883            .filter(|l| l.contains("DEEPSEEK_API_KEY"))
884            .count();
885        assert_eq!(count, 1, "expected exactly one DEEPSEEK_API_KEY line");
886    }
887
888    #[test]
889    fn set_secret_preserves_other_env_vars() {
890        let tmp = tempfile::tempdir().unwrap();
891        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
892
893        set_secret(
894            "DEEPSEEK_API_KEY",
895            "sk-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
896        )
897        .unwrap();
898        set_secret("OPENAI_API_KEY", "sk-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
899
900        let content = std::fs::read_to_string(secrets_env_path().unwrap()).unwrap();
901        assert!(content.contains("DEEPSEEK_API_KEY="));
902        assert!(content.contains("OPENAI_API_KEY="));
903    }
904
905    // ── set_value top-level [field] arm ──────────────────────────────────────
906
907    #[test]
908    fn set_value_top_level_key_roundtrips() {
909        // kills `delete match arm [field] in set_value` at line 286:
910        // A key without a dot (e.g. "timeout") should be inserted at the
911        // top level of the TOML document. If that arm is deleted, the key
912        // falls through to `_ => Error("invalid key format")` and the call fails.
913        let tmp = tempfile::tempdir().unwrap();
914        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
915
916        // Use a key that is valid but has no dot section.
917        // "timeout" is not a real config key but set_value writes raw TOML.
918        set_value("timeout", "30").expect("set_value with a top-level key must succeed");
919
920        let path = config_file_path().unwrap();
921        assert!(path.exists(), "config.toml must be created");
922        let content = std::fs::read_to_string(&path).unwrap();
923        assert!(
924            content.contains("timeout"),
925            "config.toml must contain the top-level key; got:\n{content}"
926        );
927    }
928
929    #[test]
930    fn set_value_dotted_key_roundtrips() {
931        // kills `delete match arm [section, field] in set_value` at line 278:
932        // A dotted key like "provider.model" must be written as a nested
933        // [provider] table. If that arm is deleted, the key is written as
934        // a literal "provider.model" top-level key (wrong) or falls to `[field]`.
935        let tmp = tempfile::tempdir().unwrap();
936        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
937
938        set_value("provider.model", "gpt-4o").expect("set_value with a dotted key must succeed");
939
940        let path = config_file_path().unwrap();
941        let content = std::fs::read_to_string(&path).unwrap();
942        // Proper nested TOML: [provider] section, not a literal "provider.model" key
943        assert!(
944            content.contains("[provider]"),
945            "dotted key must produce a [provider] section; got:\n{content}"
946        );
947        assert!(
948            content.contains("model"),
949            "model field must appear in the config; got:\n{content}"
950        );
951        // Must be parseable by FileConfig
952        let loaded = FileConfig::load_from(&path).unwrap().unwrap();
953        assert_eq!(loaded.provider.unwrap().model.as_deref(), Some("gpt-4o"));
954    }
955
956    #[test]
957    fn set_secret_escapes_single_quotes() {
958        // A pathological key value containing a single quote must not
959        // break out of the single-quoted shell context. The fix is
960        // standard `'\''` escaping.
961        let tmp = tempfile::tempdir().unwrap();
962        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
963
964        let weird = "sk-foo'; rm -rf ~; 'bar";
965        set_secret("DEEPSEEK_API_KEY", weird).unwrap();
966        let content = std::fs::read_to_string(secrets_env_path().unwrap()).unwrap();
967        // The exported value, when re-evaluated by sh, must equal the
968        // original `weird` string. We don't have sh here, but we can
969        // assert the escaping is present and the file is syntactically
970        // a single export line.
971        assert!(
972            content.contains("'\\''"),
973            "single quote should be escaped as '\\''; got:\n{content}"
974        );
975    }
976
977    #[test]
978    fn set_secret_first_write_has_no_blank_export_prefix() {
979        // Kills: `replace && with ||` — on an empty file `||` inserts a spurious
980        // blank line before the first export.
981        let tmp = tempfile::tempdir().unwrap();
982        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
983
984        set_secret(
985            "DEEPSEEK_API_KEY",
986            "sk-first-write-aaaaaaaaaaaaaaaaaaaaaaaa",
987        )
988        .unwrap();
989
990        let content = std::fs::read_to_string(secrets_env_path().unwrap()).unwrap();
991        assert!(
992            content.contains("export DEEPSEEK_API_KEY='sk-first-write-aaaaaaaaaaaaaaaaaaaaaaaa'"),
993            "first secret must be written; got:\n{content}"
994        );
995        assert!(
996            !content.contains("\n\nexport DEEPSEEK_API_KEY"),
997            "first write must not insert a blank line before the export; got:\n{content}"
998        );
999    }
1000
1001    #[test]
1002    fn set_secret_appends_without_extra_blank_when_file_has_trailing_newline() {
1003        // Kills: `delete !` on `!existing.ends_with('\n')` — when the file already
1004        // ends with a newline, appending must not insert an extra blank line.
1005        let tmp = tempfile::tempdir().unwrap();
1006        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
1007
1008        let path = secrets_env_path().unwrap();
1009        if let Some(parent) = path.parent() {
1010            std::fs::create_dir_all(parent).unwrap();
1011        }
1012        std::fs::write(&path, "export OTHER_KEY='keep-me'\n").unwrap();
1013
1014        set_secret(
1015            "DEEPSEEK_API_KEY",
1016            "sk-fixture-dddddddddddddddddddddddddddd",
1017        )
1018        .unwrap();
1019
1020        let content = std::fs::read_to_string(&path).unwrap();
1021        assert!(
1022            content.contains("export OTHER_KEY='keep-me'\nexport DEEPSEEK_API_KEY="),
1023            "exports must be separated by a single newline; got:\n{content}"
1024        );
1025        assert!(
1026            !content.contains("\n\nexport DEEPSEEK_API_KEY"),
1027            "must not insert a blank line before the appended export; got:\n{content}"
1028        );
1029    }
1030
1031    #[test]
1032    fn set_secret_appends_when_file_lacks_trailing_newline() {
1033        // Kills: `delete !` on `!existing.ends_with('\n')` — without the
1034        // guard, appending would glue the new export onto the previous
1035        // line when the file has no trailing newline.
1036        let tmp = tempfile::tempdir().unwrap();
1037        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path());
1038
1039        let path = secrets_env_path().unwrap();
1040        if let Some(parent) = path.parent() {
1041            std::fs::create_dir_all(parent).unwrap();
1042        }
1043        // No trailing newline on purpose.
1044        std::fs::write(&path, "export OTHER_KEY='keep-me'").unwrap();
1045
1046        set_secret(
1047            "DEEPSEEK_API_KEY",
1048            "sk-fixture-cccccccccccccccccccccccccccc",
1049        )
1050        .unwrap();
1051
1052        let content = std::fs::read_to_string(&path).unwrap();
1053        assert!(
1054            content.contains("export OTHER_KEY='keep-me'\n"),
1055            "existing line must keep its identity (newline inserted); got:\n{content}"
1056        );
1057        assert!(
1058            content.contains("export DEEPSEEK_API_KEY="),
1059            "new secret must be on its own line; got:\n{content}"
1060        );
1061        assert!(
1062            !content.contains("keep-me'export"),
1063            "must not glue the new export onto the previous line; got:\n{content}"
1064        );
1065    }
1066}