Skip to main content

opencode_ctl/
settings.rs

1//! Settings: env, user file, workspace file, defaults, with clamps, the
2//! protected-key rule and the sticky permission lock.
3
4use std::collections::{BTreeMap, HashMap};
5use std::path::{Path, PathBuf};
6
7use crate::host::Host;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum PermissionPolicy {
11    Inherit,
12    Allow,
13    Ask,
14    Deny,
15}
16
17impl PermissionPolicy {
18    pub fn as_str(self) -> &'static str {
19        match self {
20            PermissionPolicy::Inherit => "inherit",
21            PermissionPolicy::Allow => "allow",
22            PermissionPolicy::Ask => "ask",
23            PermissionPolicy::Deny => "deny",
24        }
25    }
26
27    pub fn parse(raw: &str) -> Option<Self> {
28        match raw.trim().to_ascii_lowercase().as_str() {
29            "inherit" => Some(PermissionPolicy::Inherit),
30            "allow" => Some(PermissionPolicy::Allow),
31            "ask" => Some(PermissionPolicy::Ask),
32            "deny" => Some(PermissionPolicy::Deny),
33            _ => None,
34        }
35    }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Source {
40    Env,
41    UserFile,
42    WorkspaceFile,
43    Default,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Settings {
48    pub wait_timeout_s: u64,
49    pub poll_s: u64,
50    pub stall_s: u64,
51    pub max_turn_s: u64,
52    pub call_timeout_s: u64,
53    pub output_max_bytes: usize,
54    pub body_max_bytes: usize,
55    pub wait_max_s: u64,
56    pub opencode_bin: Option<PathBuf>,
57    pub server_url: Option<String>,
58    pub default_model: Option<String>,
59    pub default_agent: Option<String>,
60    pub permission_policy: PermissionPolicy,
61    pub permissions_locked: bool,
62    pub lock_source: Option<Source>,
63    pub sources: BTreeMap<&'static str, Source>,
64}
65
66impl Default for Settings {
67    fn default() -> Self {
68        Settings {
69            wait_timeout_s: 120,
70            poll_s: 10,
71            stall_s: 90,
72            max_turn_s: 1800,
73            call_timeout_s: 30,
74            output_max_bytes: 16384,
75            body_max_bytes: if cfg!(windows) { 24000 } else { 100000 },
76            wait_max_s: 300,
77            opencode_bin: None,
78            server_url: None,
79            default_model: None,
80            default_agent: None,
81            permission_policy: PermissionPolicy::Inherit,
82            permissions_locked: false,
83            lock_source: None,
84            sources: KEYS.iter().map(|k| (*k, Source::Default)).collect(),
85        }
86    }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum NoteReason {
91    UnknownKey,
92    InvalidValue,
93    Clamped { min: i64, max: i64 },
94    ProtectedInWorkspace,
95    OverriddenByStickyLock,
96    UserinfoInUrl,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Note {
101    pub source: Source,
102    pub key: String,
103    pub value: String,
104    pub reason: NoteReason,
105}
106
107pub struct Input<'a> {
108    pub host: Host,
109    pub env: &'a dyn Fn(&str) -> Option<String>,
110    pub user_file: Option<&'a str>,
111    pub workspace_file: Option<&'a str>,
112}
113
114/// Every settings key. `wait_max_s` comes first: `wait_timeout_s` clamps to it.
115const KEYS: [&str; 14] = [
116    "wait_max_s",
117    "wait_timeout_s",
118    "poll_s",
119    "stall_s",
120    "max_turn_s",
121    "call_timeout_s",
122    "output_max_bytes",
123    "body_max_bytes",
124    "opencode_bin",
125    "server_url",
126    "default_model",
127    "default_agent",
128    "permission_policy",
129    "permissions_locked",
130];
131
132fn is_protected(key: &str) -> bool {
133    matches!(
134        key,
135        "opencode_bin"
136            | "server_url"
137            | "default_model"
138            | "default_agent"
139            | "permission_policy"
140            | "permissions_locked"
141            | "wait_max_s"
142    )
143}
144
145pub fn env_name(key: &str) -> String {
146    match key {
147        "opencode_bin" => "OPENCODE_CTL_BIN".to_string(),
148        "server_url" => "OPENCODE_CTL_SERVER".to_string(),
149        other => format!("OPENCODE_CTL_{}", other.to_ascii_uppercase()),
150    }
151}
152
153fn blank(v: &str) -> bool {
154    v.trim().is_empty()
155}
156
157/// `---` fenced `key: value` lines; `#` comments, CRLF and quotes tolerated.
158pub fn parse_frontmatter(source: &str) -> Vec<(String, String)> {
159    let mut lines = source.trim_start_matches('\u{feff}').lines();
160    let mut out = Vec::new();
161    match lines.next() {
162        Some(first) if first.trim_end() == "---" => {}
163        _ => return out,
164    }
165    for line in lines {
166        let line = line.trim();
167        if line == "---" {
168            break;
169        }
170        if line.is_empty() || line.starts_with('#') {
171            continue;
172        }
173        let Some((key, value)) = line.split_once(':') else {
174            continue;
175        };
176        let value = value.trim();
177        let value = ['"', '\'']
178            .iter()
179            .find_map(|q| value.strip_prefix(*q).and_then(|v| v.strip_suffix(*q)))
180            .unwrap_or(value);
181        out.push((key.trim().to_string(), value.to_string()));
182    }
183    out
184}
185
186pub fn ctl_home(env: &dyn Fn(&str) -> Option<String>) -> PathBuf {
187    let nonblank = |k: &str| env(k).filter(|v| !blank(v));
188    if let Some(h) = nonblank("OPENCODE_CTL_HOME") {
189        return PathBuf::from(h);
190    }
191    match nonblank("HOME").or_else(|| nonblank("USERPROFILE")) {
192        Some(h) => PathBuf::from(h).join(".opencode-ctl"),
193        None => PathBuf::from(".opencode-ctl"),
194    }
195}
196
197pub fn load_from_fs(
198    host: Host,
199    home: &Path,
200    cwd: &Path,
201    env: &dyn Fn(&str) -> Option<String>,
202) -> (Settings, Vec<Note>) {
203    let user = std::fs::read_to_string(home.join("settings.local.md")).ok();
204    let ws = host
205        .workspace_settings_rel()
206        .and_then(|rel| std::fs::read_to_string(cwd.join(rel)).ok());
207    load(Input {
208        host,
209        env,
210        user_file: user.as_deref(),
211        workspace_file: ws.as_deref(),
212    })
213}
214
215fn userinfo_in_url(url: &str) -> bool {
216    let rest = url.split_once("://").map_or(url, |(_, r)| r);
217    let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
218    authority.contains('@')
219}
220
221fn redact_userinfo(url: &str) -> String {
222    let (scheme, rest) = match url.split_once("://") {
223        Some((s, r)) => (format!("{s}://"), r),
224        None => (String::new(), url),
225    };
226    let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
227    let (authority, tail) = rest.split_at(end);
228    let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
229    format!("{scheme}***@{host}{tail}")
230}
231
232fn clamp_bounds(key: &str, source: Source, host: Host) -> (u64, u64, bool) {
233    match key {
234        "poll_s" => (2, 60, false),
235        "stall_s" => (30, 600, false),
236        "max_turn_s" => (60, 7200, false),
237        "call_timeout_s" => (5, 120, false),
238        "body_max_bytes" => (1024, 100000, false),
239        "output_max_bytes" => (
240            1024,
241            if source == Source::WorkspaceFile {
242                65536
243            } else {
244                1048576
245            },
246            false,
247        ),
248        "wait_max_s" => (30, if host == Host::Opencode { 300 } else { 600 }, false),
249        _ => (0, u64::MAX, true), // wait_timeout_s: ceiling is the resolved wait_max_s
250    }
251}
252
253pub fn load(input: Input) -> (Settings, Vec<Note>) {
254    let mut s = Settings::default();
255    let mut notes = Vec::new();
256
257    let user: HashMap<String, String> = input
258        .user_file
259        .map(|f| parse_frontmatter(f).into_iter().collect())
260        .unwrap_or_default();
261    let ws: HashMap<String, String> = input
262        .workspace_file
263        .map(|f| parse_frontmatter(f).into_iter().collect())
264        .unwrap_or_default();
265
266    for (src, map) in [(Source::UserFile, &user), (Source::WorkspaceFile, &ws)] {
267        let mut keys: Vec<&String> = map.keys().filter(|k| !KEYS.contains(&k.as_str())).collect();
268        keys.sort();
269        for k in keys {
270            notes.push(Note {
271                source: src,
272                key: k.clone(),
273                value: map[k].clone(),
274                reason: NoteReason::UnknownKey,
275            });
276        }
277    }
278
279    // Candidates for a key in precedence order. Blank env values are unset
280    // (except the server key, whose empty value is meaningful); blank file
281    // values are unset.
282    let cands = |key: &str| -> Vec<(Source, String)> {
283        let mut v = Vec::new();
284        if let Some(e) = (input.env)(&env_name(key)) {
285            if !blank(&e) || key == "server_url" {
286                v.push((Source::Env, e));
287            }
288        }
289        for (src, map) in [(Source::UserFile, &user), (Source::WorkspaceFile, &ws)] {
290            if let Some(x) = map.get(key).filter(|x| !blank(x)) {
291                v.push((src, x.clone()));
292            }
293        }
294        v
295    };
296
297    for key in KEYS {
298        if key == "permission_policy" || key == "permissions_locked" {
299            continue;
300        }
301        let mut resolved = false;
302        for (src, raw) in cands(key) {
303            if src == Source::WorkspaceFile && is_protected(key) {
304                notes.push(Note {
305                    source: src,
306                    key: key.to_string(),
307                    value: raw,
308                    reason: NoteReason::ProtectedInWorkspace,
309                });
310                continue;
311            }
312            if resolved {
313                continue;
314            }
315            let mut note = |reason: NoteReason, value: String| {
316                notes.push(Note {
317                    source: src,
318                    key: key.to_string(),
319                    value,
320                    reason,
321                })
322            };
323            let trimmed = raw.trim();
324            match key {
325                "opencode_bin" => s.opencode_bin = Some(PathBuf::from(trimmed)),
326                "default_model" => s.default_model = Some(trimmed.to_string()),
327                "default_agent" => s.default_agent = Some(trimmed.to_string()),
328                "server_url" => {
329                    if trimmed.is_empty() {
330                        s.server_url = None;
331                    } else if userinfo_in_url(trimmed) {
332                        note(NoteReason::UserinfoInUrl, redact_userinfo(trimmed));
333                        continue;
334                    } else {
335                        s.server_url = Some(trimmed.to_string());
336                    }
337                }
338                _ => {
339                    let Ok(n) = trimmed.parse::<u64>() else {
340                        note(NoteReason::InvalidValue, raw.clone());
341                        continue;
342                    };
343                    let (min, mut max, ceiling_is_wait_max) = clamp_bounds(key, src, input.host);
344                    let min = if ceiling_is_wait_max { 5 } else { min };
345                    if ceiling_is_wait_max {
346                        max = s.wait_max_s;
347                    }
348                    let v = n.clamp(min, max);
349                    if v != n {
350                        note(
351                            NoteReason::Clamped {
352                                min: min as i64,
353                                max: max as i64,
354                            },
355                            raw.clone(),
356                        );
357                    }
358                    match key {
359                        "wait_max_s" => s.wait_max_s = v,
360                        "wait_timeout_s" => s.wait_timeout_s = v,
361                        "poll_s" => s.poll_s = v,
362                        "stall_s" => s.stall_s = v,
363                        "max_turn_s" => s.max_turn_s = v,
364                        "call_timeout_s" => s.call_timeout_s = v,
365                        "output_max_bytes" => s.output_max_bytes = v as usize,
366                        _ => s.body_max_bytes = v as usize,
367                    }
368                }
369            }
370            s.sources.insert(key, src);
371            resolved = true;
372        }
373    }
374
375    if s.sources["wait_timeout_s"] == Source::Default && s.wait_timeout_s > s.wait_max_s {
376        s.wait_timeout_s = s.wait_max_s;
377    }
378
379    resolve_permissions(&mut s, &mut notes, &cands);
380    (s, notes)
381}
382
383fn resolve_permissions(
384    s: &mut Settings,
385    notes: &mut Vec<Note>,
386    cands: &dyn Fn(&str) -> Vec<(Source, String)>,
387) {
388    let push = |notes: &mut Vec<Note>, src, key: &str, value: String, reason| {
389        notes.push(Note {
390            source: src,
391            key: key.to_string(),
392            value,
393            reason,
394        })
395    };
396    let protected_only = |notes: &mut Vec<Note>, key: &str| -> Vec<(Source, String)> {
397        let mut kept = Vec::new();
398        for (src, raw) in cands(key) {
399            if src == Source::WorkspaceFile {
400                push(notes, src, key, raw, NoteReason::ProtectedInWorkspace);
401            } else {
402                kept.push((src, raw));
403            }
404        }
405        kept
406    };
407
408    // The lock: any protected source saying true wins over every false.
409    let mut lock_vals: Vec<(Source, String, bool)> = Vec::new();
410    for (src, raw) in protected_only(notes, "permissions_locked") {
411        match raw.trim().to_ascii_lowercase().as_str() {
412            "true" => lock_vals.push((src, raw, true)),
413            "false" => lock_vals.push((src, raw, false)),
414            _ => push(
415                notes,
416                src,
417                "permissions_locked",
418                raw,
419                NoteReason::InvalidValue,
420            ),
421        }
422    }
423    if let Some((src, _, _)) = lock_vals.iter().find(|v| v.2) {
424        s.permissions_locked = true;
425        s.lock_source = Some(*src);
426        s.sources.insert("permissions_locked", *src);
427        for (osrc, raw, _) in lock_vals.iter().filter(|v| !v.2) {
428            push(
429                notes,
430                *osrc,
431                "permissions_locked",
432                raw.clone(),
433                NoteReason::OverriddenByStickyLock,
434            );
435        }
436    } else if let Some((src, _, _)) = lock_vals.first() {
437        s.sources.insert("permissions_locked", *src);
438    }
439
440    // The policy: from the lock's source when locked, else first valid.
441    let mut resolved = false;
442    for (src, raw) in protected_only(notes, "permission_policy") {
443        let Some(p) = PermissionPolicy::parse(&raw) else {
444            push(
445                notes,
446                src,
447                "permission_policy",
448                raw,
449                NoteReason::InvalidValue,
450            );
451            continue;
452        };
453        let wins = match s.lock_source {
454            Some(l) => src == l,
455            None => !resolved,
456        };
457        if wins && !resolved {
458            s.permission_policy = p;
459            s.sources.insert("permission_policy", src);
460            resolved = true;
461        } else if s.lock_source.is_some() {
462            push(
463                notes,
464                src,
465                "permission_policy",
466                raw,
467                NoteReason::OverriddenByStickyLock,
468            );
469        }
470    }
471}