Skip to main content

opencode_cloud_core/config/
schema.rs

1//! Configuration schema for opencode-cloud
2//!
3//! Defines the structure and defaults for the config.json file.
4
5use super::paths::{get_config_dir, get_data_dir};
6use crate::docker::volume::{MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION};
7use serde::{Deserialize, Serialize};
8use std::net::{IpAddr, Ipv4Addr};
9/// Main configuration structure for opencode-cloud
10///
11/// Serialized to/from `~/.config/opencode-cloud/config.json`
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13#[serde(deny_unknown_fields)]
14pub struct Config {
15    /// Config file version for migrations
16    pub version: u32,
17
18    /// Port for the opencode web UI (default: 3000)
19    #[serde(default = "default_opencode_web_port")]
20    pub opencode_web_port: u16,
21
22    /// Bind address (default: "localhost")
23    /// Use "localhost" for local-only access (secure default)
24    /// Use "0.0.0.0" for network access (requires explicit opt-in)
25    #[serde(default = "default_bind")]
26    pub bind: String,
27
28    /// Auto-restart service on crash (default: true)
29    #[serde(default = "default_auto_restart")]
30    pub auto_restart: bool,
31
32    /// Boot mode for service registration (default: "user")
33    /// "user" - Service starts on user login (no root required)
34    /// "system" - Service starts on boot (requires root)
35    #[serde(default = "default_boot_mode")]
36    pub boot_mode: String,
37
38    /// Number of restart attempts on crash (default: 3)
39    #[serde(default = "default_restart_retries")]
40    pub restart_retries: u32,
41
42    /// Seconds between restart attempts (default: 5)
43    #[serde(default = "default_restart_delay")]
44    pub restart_delay: u32,
45
46    /// Username for opencode basic auth (DEPRECATED - use PAM users via `occ user add` instead)
47    ///
48    /// This field is kept for backward compatibility but is ignored.
49    /// New deployments should create users via `occ user add` which uses PAM authentication.
50    /// Legacy deployments can migrate by running `occ user add <username>`.
51    #[serde(default)]
52    pub auth_username: Option<String>,
53
54    /// Password for opencode basic auth (DEPRECATED - use PAM users via `occ user add` instead)
55    ///
56    /// This field is kept for backward compatibility but is ignored.
57    /// Passwords are stored in the container's /etc/shadow via PAM, not in config files.
58    #[serde(default)]
59    pub auth_password: Option<String>,
60
61    /// Environment variables passed to container (default: empty)
62    /// Format: ["KEY=value", "KEY2=value2"]
63    #[serde(default)]
64    pub container_env: Vec<String>,
65
66    /// Bind address for opencode web UI (default: "127.0.0.1")
67    /// Use "0.0.0.0" or "::" for network exposure (requires explicit opt-in)
68    #[serde(default = "default_bind_address")]
69    pub bind_address: String,
70
71    /// Trust proxy headers (X-Forwarded-For, etc.) for load balancer deployments
72    #[serde(default)]
73    pub trust_proxy: bool,
74
75    /// Allow unauthenticated access when network exposed
76    /// Requires double confirmation on first start
77    #[serde(default)]
78    pub allow_unauthenticated_network: bool,
79
80    /// Maximum auth attempts before rate limiting
81    #[serde(default = "default_rate_limit_attempts")]
82    pub rate_limit_attempts: u32,
83
84    /// Rate limit window in seconds
85    #[serde(default = "default_rate_limit_window")]
86    pub rate_limit_window_seconds: u32,
87
88    /// List of usernames configured in container (for persistence tracking)
89    /// Passwords are NOT stored here - only in container's /etc/shadow
90    #[serde(default)]
91    pub users: Vec<String>,
92
93    /// Cockpit web console port (default: 9090)
94    /// Only used when cockpit_enabled is true
95    #[serde(default = "default_cockpit_port")]
96    pub cockpit_port: u16,
97
98    /// Enable Cockpit web console (default: false)
99    ///
100    /// When enabled:
101    /// - Container uses systemd as init (required for Cockpit)
102    /// - Requires Linux host with native Docker (does NOT work on macOS Docker Desktop)
103    /// - Cockpit web UI accessible at cockpit_port
104    ///
105    /// When disabled (default):
106    /// - Container uses tini as init (lightweight, works everywhere)
107    /// - Works on macOS, Linux, and Windows
108    /// - No Cockpit web UI
109    #[serde(default = "default_cockpit_enabled")]
110    pub cockpit_enabled: bool,
111
112    /// Source of Docker image: 'prebuilt' (pull from registry) or 'build' (compile locally)
113    #[serde(default = "default_image_source")]
114    pub image_source: String,
115
116    /// When to check for updates: 'always' (every start), 'once' (once per version), 'never'
117    #[serde(default = "default_update_check")]
118    pub update_check: String,
119
120    /// Bind mounts to apply when starting the container
121    /// Format: ["/host/path:/container/path", "/host:/mnt:ro"]
122    #[serde(default = "default_mounts")]
123    pub mounts: Vec<String>,
124}
125
126fn default_opencode_web_port() -> u16 {
127    3000
128}
129
130fn default_bind() -> String {
131    "localhost".to_string()
132}
133
134fn default_auto_restart() -> bool {
135    true
136}
137
138fn default_boot_mode() -> String {
139    "user".to_string()
140}
141
142fn default_restart_retries() -> u32 {
143    3
144}
145
146fn default_restart_delay() -> u32 {
147    5
148}
149
150fn default_bind_address() -> String {
151    "127.0.0.1".to_string()
152}
153
154fn default_rate_limit_attempts() -> u32 {
155    5
156}
157
158fn default_rate_limit_window() -> u32 {
159    60
160}
161
162fn default_cockpit_port() -> u16 {
163    9090
164}
165
166fn default_cockpit_enabled() -> bool {
167    false
168}
169
170fn default_image_source() -> String {
171    "prebuilt".to_string()
172}
173
174fn default_update_check() -> String {
175    "always".to_string()
176}
177
178pub fn default_mounts() -> Vec<String> {
179    let maybe_data_dir = get_data_dir();
180    let maybe_config_dir = get_config_dir();
181    let (Some(data_dir), Some(config_dir)) = (maybe_data_dir, maybe_config_dir) else {
182        return Vec::new();
183    };
184
185    let session_dir = data_dir.join("data");
186    let workspace_dir = data_dir.join("workspace");
187    let config_dir = config_dir.join("container");
188
189    vec![
190        format!("{}:{MOUNT_SESSION}", session_dir.display()),
191        format!("{}:{MOUNT_PROJECTS}", workspace_dir.display()),
192        format!("{}:{MOUNT_CONFIG}", config_dir.display()),
193    ]
194}
195
196/// Validate and parse a bind address string
197///
198/// Accepts:
199/// - IPv4 addresses: "127.0.0.1", "0.0.0.0"
200/// - IPv6 addresses: "::1", "::"
201/// - Bracketed IPv6: "[::1]"
202/// - "localhost" (resolves to 127.0.0.1)
203///
204/// Returns the parsed IpAddr or an error message.
205pub fn validate_bind_address(addr: &str) -> Result<IpAddr, String> {
206    let trimmed = addr.trim();
207
208    // Handle "localhost" as special case
209    if trimmed.eq_ignore_ascii_case("localhost") {
210        return Ok(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
211    }
212
213    // Strip brackets from IPv6 addresses like "[::1]"
214    let stripped = if trimmed.starts_with('[') && trimmed.ends_with(']') {
215        &trimmed[1..trimmed.len() - 1]
216    } else {
217        trimmed
218    };
219
220    stripped.parse::<IpAddr>().map_err(|_| {
221        format!("Invalid IP address: '{addr}'. Use 127.0.0.1, ::1, 0.0.0.0, ::, or localhost")
222    })
223}
224
225impl Default for Config {
226    fn default() -> Self {
227        Self {
228            version: 1,
229            opencode_web_port: default_opencode_web_port(),
230            bind: default_bind(),
231            auto_restart: default_auto_restart(),
232            boot_mode: default_boot_mode(),
233            restart_retries: default_restart_retries(),
234            restart_delay: default_restart_delay(),
235            auth_username: None,
236            auth_password: None,
237            container_env: Vec::new(),
238            bind_address: default_bind_address(),
239            trust_proxy: false,
240            allow_unauthenticated_network: false,
241            rate_limit_attempts: default_rate_limit_attempts(),
242            rate_limit_window_seconds: default_rate_limit_window(),
243            users: Vec::new(),
244            cockpit_port: default_cockpit_port(),
245            cockpit_enabled: default_cockpit_enabled(),
246            image_source: default_image_source(),
247            update_check: default_update_check(),
248            mounts: default_mounts(),
249        }
250    }
251}
252
253impl Config {
254    /// Create a new Config with default values
255    pub fn new() -> Self {
256        Self::default()
257    }
258
259    /// Check if required auth credentials are configured
260    ///
261    /// Returns true if:
262    /// - The users array is non-empty (PAM-based auth - preferred), OR
263    /// - Both auth_username and auth_password are Some and non-empty (legacy - deprecated)
264    ///
265    /// **Note:** Legacy auth_username/auth_password fields are deprecated and ignored in favor of PAM users.
266    /// This method still checks them for backward compatibility, but new deployments should use `occ user add`.
267    ///
268    /// This is used to determine if the setup wizard needs to run.
269    pub fn has_required_auth(&self) -> bool {
270        // New PAM-based auth: users array
271        if !self.users.is_empty() {
272            return true;
273        }
274
275        // Legacy basic auth: username/password
276        match (&self.auth_username, &self.auth_password) {
277            (Some(username), Some(password)) => !username.is_empty() && !password.is_empty(),
278            _ => false,
279        }
280    }
281
282    /// Check if the bind address exposes the service to the network
283    ///
284    /// Returns true if bind_address is "0.0.0.0" (IPv4 all interfaces) or
285    /// "::" (IPv6 all interfaces).
286    pub fn is_network_exposed(&self) -> bool {
287        match validate_bind_address(&self.bind_address) {
288            Ok(IpAddr::V4(ip)) => ip.is_unspecified(),
289            Ok(IpAddr::V6(ip)) => ip.is_unspecified(),
290            Err(_) => false, // Invalid addresses are not considered exposed
291        }
292    }
293
294    /// Check if the bind address is localhost-only
295    ///
296    /// Returns true if bind_address is "127.0.0.1", "::1", or "localhost".
297    pub fn is_localhost(&self) -> bool {
298        match validate_bind_address(&self.bind_address) {
299            Ok(ip) => ip.is_loopback(),
300            Err(_) => {
301                // Also check for "localhost" string directly
302                self.bind_address.eq_ignore_ascii_case("localhost")
303            }
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn test_default_config() {
314        let config = Config::default();
315        assert_eq!(config.version, 1);
316        assert_eq!(config.opencode_web_port, 3000);
317        assert_eq!(config.bind, "localhost");
318        assert!(config.auto_restart);
319        assert_eq!(config.boot_mode, "user");
320        assert_eq!(config.restart_retries, 3);
321        assert_eq!(config.restart_delay, 5);
322        assert!(config.auth_username.is_none());
323        assert!(config.auth_password.is_none());
324        assert!(config.container_env.is_empty());
325        // Security fields
326        assert_eq!(config.bind_address, "127.0.0.1");
327        assert!(!config.trust_proxy);
328        assert!(!config.allow_unauthenticated_network);
329        assert_eq!(config.rate_limit_attempts, 5);
330        assert_eq!(config.rate_limit_window_seconds, 60);
331        assert!(config.users.is_empty());
332        assert_eq!(config.mounts, default_mounts());
333    }
334
335    #[test]
336    fn test_serialize_deserialize_roundtrip() {
337        let config = Config::default();
338        let json = serde_json::to_string(&config).unwrap();
339        let parsed: Config = serde_json::from_str(&json).unwrap();
340        assert_eq!(config, parsed);
341    }
342
343    #[test]
344    fn test_deserialize_with_missing_optional_fields() {
345        let json = r#"{"version": 1}"#;
346        let config: Config = serde_json::from_str(json).unwrap();
347        assert_eq!(config.version, 1);
348        assert_eq!(config.opencode_web_port, 3000);
349        assert_eq!(config.bind, "localhost");
350        assert!(config.auto_restart);
351        assert_eq!(config.boot_mode, "user");
352        assert_eq!(config.restart_retries, 3);
353        assert_eq!(config.restart_delay, 5);
354        assert!(config.auth_username.is_none());
355        assert!(config.auth_password.is_none());
356        assert!(config.container_env.is_empty());
357        // Security fields should have defaults
358        assert_eq!(config.bind_address, "127.0.0.1");
359        assert!(!config.trust_proxy);
360        assert!(!config.allow_unauthenticated_network);
361        assert_eq!(config.rate_limit_attempts, 5);
362        assert_eq!(config.rate_limit_window_seconds, 60);
363        assert!(config.users.is_empty());
364    }
365
366    #[test]
367    fn test_serialize_deserialize_roundtrip_with_service_fields() {
368        let config = Config {
369            version: 1,
370            opencode_web_port: 9000,
371            bind: "0.0.0.0".to_string(),
372            auto_restart: false,
373            boot_mode: "system".to_string(),
374            restart_retries: 5,
375            restart_delay: 10,
376            auth_username: None,
377            auth_password: None,
378            container_env: Vec::new(),
379            bind_address: "0.0.0.0".to_string(),
380            trust_proxy: true,
381            allow_unauthenticated_network: false,
382            rate_limit_attempts: 10,
383            rate_limit_window_seconds: 120,
384            users: vec!["admin".to_string()],
385            cockpit_port: 9090,
386            cockpit_enabled: true,
387            image_source: default_image_source(),
388            update_check: default_update_check(),
389            mounts: Vec::new(),
390        };
391        let json = serde_json::to_string(&config).unwrap();
392        let parsed: Config = serde_json::from_str(&json).unwrap();
393        assert_eq!(config, parsed);
394        assert_eq!(parsed.boot_mode, "system");
395        assert_eq!(parsed.restart_retries, 5);
396        assert_eq!(parsed.restart_delay, 10);
397        assert_eq!(parsed.bind_address, "0.0.0.0");
398        assert!(parsed.trust_proxy);
399        assert_eq!(parsed.rate_limit_attempts, 10);
400        assert_eq!(parsed.users, vec!["admin"]);
401    }
402
403    #[test]
404    fn test_reject_unknown_fields() {
405        let json = r#"{"version": 1, "unknown_field": "value"}"#;
406        let result: Result<Config, _> = serde_json::from_str(json);
407        assert!(result.is_err());
408    }
409
410    #[test]
411    fn test_serialize_deserialize_roundtrip_with_auth_fields() {
412        let config = Config {
413            auth_username: Some("admin".to_string()),
414            auth_password: Some("secret123".to_string()),
415            container_env: vec!["FOO=bar".to_string(), "BAZ=qux".to_string()],
416            ..Config::default()
417        };
418        let json = serde_json::to_string(&config).unwrap();
419        let parsed: Config = serde_json::from_str(&json).unwrap();
420        assert_eq!(config, parsed);
421        assert_eq!(parsed.auth_username, Some("admin".to_string()));
422        assert_eq!(parsed.auth_password, Some("secret123".to_string()));
423        assert_eq!(parsed.container_env, vec!["FOO=bar", "BAZ=qux"]);
424    }
425
426    #[test]
427    fn test_has_required_auth_returns_false_when_both_none() {
428        let config = Config::default();
429        assert!(!config.has_required_auth());
430    }
431
432    #[test]
433    fn test_has_required_auth_returns_false_when_username_none() {
434        let config = Config {
435            auth_username: None,
436            auth_password: Some("secret".to_string()),
437            ..Config::default()
438        };
439        assert!(!config.has_required_auth());
440    }
441
442    #[test]
443    fn test_has_required_auth_returns_false_when_password_none() {
444        let config = Config {
445            auth_username: Some("admin".to_string()),
446            auth_password: None,
447            ..Config::default()
448        };
449        assert!(!config.has_required_auth());
450    }
451
452    #[test]
453    fn test_has_required_auth_returns_false_when_username_empty() {
454        let config = Config {
455            auth_username: Some(String::new()),
456            auth_password: Some("secret".to_string()),
457            ..Config::default()
458        };
459        assert!(!config.has_required_auth());
460    }
461
462    #[test]
463    fn test_has_required_auth_returns_false_when_password_empty() {
464        let config = Config {
465            auth_username: Some("admin".to_string()),
466            auth_password: Some(String::new()),
467            ..Config::default()
468        };
469        assert!(!config.has_required_auth());
470    }
471
472    #[test]
473    fn test_has_required_auth_returns_true_when_both_set() {
474        let config = Config {
475            auth_username: Some("admin".to_string()),
476            auth_password: Some("secret123".to_string()),
477            ..Config::default()
478        };
479        assert!(config.has_required_auth());
480    }
481
482    // Tests for validate_bind_address
483
484    #[test]
485    fn test_validate_bind_address_ipv4_localhost() {
486        let result = validate_bind_address("127.0.0.1");
487        assert!(result.is_ok());
488        let ip = result.unwrap();
489        assert!(ip.is_loopback());
490    }
491
492    #[test]
493    fn test_validate_bind_address_ipv4_all_interfaces() {
494        let result = validate_bind_address("0.0.0.0");
495        assert!(result.is_ok());
496        let ip = result.unwrap();
497        assert!(ip.is_unspecified());
498    }
499
500    #[test]
501    fn test_validate_bind_address_ipv6_localhost() {
502        let result = validate_bind_address("::1");
503        assert!(result.is_ok());
504        let ip = result.unwrap();
505        assert!(ip.is_loopback());
506    }
507
508    #[test]
509    fn test_validate_bind_address_ipv6_all_interfaces() {
510        let result = validate_bind_address("::");
511        assert!(result.is_ok());
512        let ip = result.unwrap();
513        assert!(ip.is_unspecified());
514    }
515
516    #[test]
517    fn test_validate_bind_address_localhost_string() {
518        let result = validate_bind_address("localhost");
519        assert!(result.is_ok());
520        assert_eq!(result.unwrap().to_string(), "127.0.0.1");
521    }
522
523    #[test]
524    fn test_validate_bind_address_localhost_case_insensitive() {
525        let result = validate_bind_address("LOCALHOST");
526        assert!(result.is_ok());
527        assert_eq!(result.unwrap().to_string(), "127.0.0.1");
528    }
529
530    #[test]
531    fn test_validate_bind_address_bracketed_ipv6() {
532        let result = validate_bind_address("[::1]");
533        assert!(result.is_ok());
534        assert!(result.unwrap().is_loopback());
535    }
536
537    #[test]
538    fn test_validate_bind_address_invalid() {
539        let result = validate_bind_address("not-an-ip");
540        assert!(result.is_err());
541        assert!(result.unwrap_err().contains("Invalid IP address"));
542    }
543
544    #[test]
545    fn test_validate_bind_address_whitespace() {
546        let result = validate_bind_address("  127.0.0.1  ");
547        assert!(result.is_ok());
548    }
549
550    // Tests for is_network_exposed
551
552    #[test]
553    fn test_is_network_exposed_ipv4_all() {
554        let config = Config {
555            bind_address: "0.0.0.0".to_string(),
556            ..Config::default()
557        };
558        assert!(config.is_network_exposed());
559    }
560
561    #[test]
562    fn test_is_network_exposed_ipv6_all() {
563        let config = Config {
564            bind_address: "::".to_string(),
565            ..Config::default()
566        };
567        assert!(config.is_network_exposed());
568    }
569
570    #[test]
571    fn test_is_network_exposed_localhost_false() {
572        let config = Config::default();
573        assert!(!config.is_network_exposed());
574    }
575
576    #[test]
577    fn test_is_network_exposed_ipv6_localhost_false() {
578        let config = Config {
579            bind_address: "::1".to_string(),
580            ..Config::default()
581        };
582        assert!(!config.is_network_exposed());
583    }
584
585    // Tests for is_localhost
586
587    #[test]
588    fn test_is_localhost_ipv4() {
589        let config = Config {
590            bind_address: "127.0.0.1".to_string(),
591            ..Config::default()
592        };
593        assert!(config.is_localhost());
594    }
595
596    #[test]
597    fn test_is_localhost_ipv6() {
598        let config = Config {
599            bind_address: "::1".to_string(),
600            ..Config::default()
601        };
602        assert!(config.is_localhost());
603    }
604
605    #[test]
606    fn test_is_localhost_string() {
607        let config = Config {
608            bind_address: "localhost".to_string(),
609            ..Config::default()
610        };
611        assert!(config.is_localhost());
612    }
613
614    #[test]
615    fn test_is_localhost_all_interfaces_false() {
616        let config = Config {
617            bind_address: "0.0.0.0".to_string(),
618            ..Config::default()
619        };
620        assert!(!config.is_localhost());
621    }
622
623    // Tests for security fields serialization
624
625    #[test]
626    fn test_serialize_deserialize_with_security_fields() {
627        let config = Config {
628            bind_address: "0.0.0.0".to_string(),
629            trust_proxy: true,
630            allow_unauthenticated_network: true,
631            rate_limit_attempts: 10,
632            rate_limit_window_seconds: 120,
633            users: vec!["admin".to_string(), "developer".to_string()],
634            ..Config::default()
635        };
636        let json = serde_json::to_string(&config).unwrap();
637        let parsed: Config = serde_json::from_str(&json).unwrap();
638        assert_eq!(config, parsed);
639        assert_eq!(parsed.bind_address, "0.0.0.0");
640        assert!(parsed.trust_proxy);
641        assert!(parsed.allow_unauthenticated_network);
642        assert_eq!(parsed.rate_limit_attempts, 10);
643        assert_eq!(parsed.rate_limit_window_seconds, 120);
644        assert_eq!(parsed.users, vec!["admin", "developer"]);
645    }
646
647    // Tests for Cockpit fields
648
649    #[test]
650    fn test_default_config_cockpit_fields() {
651        let config = Config::default();
652        assert_eq!(config.cockpit_port, 9090);
653        // cockpit_enabled defaults to false (requires Linux host)
654        assert!(!config.cockpit_enabled);
655    }
656
657    #[test]
658    fn test_serialize_deserialize_with_cockpit_fields() {
659        let config = Config {
660            cockpit_port: 9091,
661            cockpit_enabled: false,
662            ..Config::default()
663        };
664        let json = serde_json::to_string(&config).unwrap();
665        let parsed: Config = serde_json::from_str(&json).unwrap();
666        assert_eq!(parsed.cockpit_port, 9091);
667        assert!(!parsed.cockpit_enabled);
668    }
669
670    #[test]
671    fn test_cockpit_fields_default_on_missing() {
672        // Old configs without cockpit fields should get defaults
673        let json = r#"{"version": 1}"#;
674        let config: Config = serde_json::from_str(json).unwrap();
675        assert_eq!(config.cockpit_port, 9090);
676        // cockpit_enabled defaults to false (requires Linux host)
677        assert!(!config.cockpit_enabled);
678    }
679
680    // Tests for image_source and update_check fields
681
682    #[test]
683    fn test_default_config_image_fields() {
684        let config = Config::default();
685        assert_eq!(config.image_source, "prebuilt");
686        assert_eq!(config.update_check, "always");
687    }
688
689    #[test]
690    fn test_serialize_deserialize_with_image_fields() {
691        let config = Config {
692            image_source: "build".to_string(),
693            update_check: "never".to_string(),
694            ..Config::default()
695        };
696        let json = serde_json::to_string(&config).unwrap();
697        let parsed: Config = serde_json::from_str(&json).unwrap();
698        assert_eq!(parsed.image_source, "build");
699        assert_eq!(parsed.update_check, "never");
700    }
701
702    #[test]
703    fn test_image_fields_default_on_missing() {
704        // Old configs without image fields should get defaults
705        let json = r#"{"version": 1}"#;
706        let config: Config = serde_json::from_str(json).unwrap();
707        assert_eq!(config.image_source, "prebuilt");
708        assert_eq!(config.update_check, "always");
709    }
710
711    // Tests for mounts field
712
713    #[test]
714    fn test_default_config_mounts_field() {
715        let config = Config::default();
716        assert_eq!(config.mounts, default_mounts());
717    }
718
719    #[test]
720    fn test_serialize_deserialize_with_mounts() {
721        let config = Config {
722            mounts: vec![
723                "/home/user/data:/home/opencode/workspace/data".to_string(),
724                "/home/user/config:/etc/app:ro".to_string(),
725            ],
726            ..Config::default()
727        };
728        let json = serde_json::to_string(&config).unwrap();
729        let parsed: Config = serde_json::from_str(&json).unwrap();
730        assert_eq!(parsed.mounts.len(), 2);
731        assert_eq!(
732            parsed.mounts[0],
733            "/home/user/data:/home/opencode/workspace/data"
734        );
735        assert_eq!(parsed.mounts[1], "/home/user/config:/etc/app:ro");
736    }
737
738    #[test]
739    fn test_mounts_field_default_on_missing() {
740        // Old configs without mounts field should get default mounts
741        let json = r#"{"version": 1}"#;
742        let config: Config = serde_json::from_str(json).unwrap();
743        assert_eq!(config.mounts, default_mounts());
744    }
745}