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