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_SSH, 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
187        .join(".config")
188        .join("opencode-cloud")
189        .join("opencode");
190    let workspace_dir = home_dir.join("opencode");
191
192    // opencode-cloud-owned paths under ~/.local/share/opencode-cloud/
193    let occ_data_dir = home_dir.join(".local").join("share").join("opencode-cloud");
194    let ssh_dir = occ_data_dir.join("ssh");
195
196    vec![
197        format!("{}:{MOUNT_SESSION}", data_dir.display()),
198        format!("{}:{MOUNT_STATE}", state_dir.display()),
199        format!("{}:{MOUNT_CACHE}", cache_dir.display()),
200        format!("{}:{MOUNT_PROJECTS}", workspace_dir.display()),
201        format!("{}:{MOUNT_CONFIG}", config_dir.display()),
202        format!("{}:{MOUNT_SSH}", ssh_dir.display()),
203    ]
204}
205
206/// Validate and parse a bind address string
207///
208/// Accepts:
209/// - IPv4 addresses: "127.0.0.1", "0.0.0.0"
210/// - IPv6 addresses: "::1", "::"
211/// - Bracketed IPv6: "[::1]"
212/// - "localhost" (resolves to 127.0.0.1)
213///
214/// Returns the parsed IpAddr or an error message.
215pub fn validate_bind_address(addr: &str) -> Result<IpAddr, String> {
216    let trimmed = addr.trim();
217
218    // Handle "localhost" as special case
219    if trimmed.eq_ignore_ascii_case("localhost") {
220        return Ok(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
221    }
222
223    // Strip brackets from IPv6 addresses like "[::1]"
224    let stripped = if trimmed.starts_with('[') && trimmed.ends_with(']') {
225        &trimmed[1..trimmed.len() - 1]
226    } else {
227        trimmed
228    };
229
230    stripped.parse::<IpAddr>().map_err(|_| {
231        format!("Invalid IP address: '{addr}'. Use 127.0.0.1, ::1, 0.0.0.0, ::, or localhost")
232    })
233}
234
235impl Default for Config {
236    fn default() -> Self {
237        Self {
238            version: 1,
239            opencode_web_port: default_opencode_web_port(),
240            bind: default_bind(),
241            auto_restart: default_auto_restart(),
242            boot_mode: default_boot_mode(),
243            restart_retries: default_restart_retries(),
244            restart_delay: default_restart_delay(),
245            auth_username: None,
246            auth_password: None,
247            container_env: Vec::new(),
248            bind_address: default_bind_address(),
249            trust_proxy: false,
250            allow_unauthenticated_network: false,
251            rate_limit_attempts: default_rate_limit_attempts(),
252            rate_limit_window_seconds: default_rate_limit_window(),
253            users: Vec::new(),
254            cockpit_port: default_cockpit_port(),
255            cockpit_enabled: default_cockpit_enabled(),
256            image_source: default_image_source(),
257            update_check: default_update_check(),
258            mounts: default_mounts(),
259        }
260    }
261}
262
263impl Config {
264    /// Create a new Config with default values
265    pub fn new() -> Self {
266        Self::default()
267    }
268
269    /// Check if required auth credentials are configured
270    ///
271    /// Returns true if:
272    /// - The users array is non-empty (PAM-based auth - preferred), OR
273    /// - Both auth_username and auth_password are Some and non-empty (legacy - deprecated)
274    ///
275    /// **Note:** Legacy auth_username/auth_password fields are deprecated and ignored in favor of PAM users.
276    /// This method still checks them for backward compatibility, but new deployments should use `occ user add`.
277    ///
278    /// This is used to determine if the setup wizard needs to run.
279    pub fn has_required_auth(&self) -> bool {
280        // New PAM-based auth: users array
281        if !self.users.is_empty() {
282            return true;
283        }
284
285        // Legacy basic auth: username/password
286        match (&self.auth_username, &self.auth_password) {
287            (Some(username), Some(password)) => !username.is_empty() && !password.is_empty(),
288            _ => false,
289        }
290    }
291
292    /// Check if the bind address exposes the service to the network
293    ///
294    /// Returns true if bind_address is "0.0.0.0" (IPv4 all interfaces) or
295    /// "::" (IPv6 all interfaces).
296    pub fn is_network_exposed(&self) -> bool {
297        match validate_bind_address(&self.bind_address) {
298            Ok(IpAddr::V4(ip)) => ip.is_unspecified(),
299            Ok(IpAddr::V6(ip)) => ip.is_unspecified(),
300            Err(_) => false, // Invalid addresses are not considered exposed
301        }
302    }
303
304    /// Check if the bind address is localhost-only
305    ///
306    /// Returns true if bind_address is "127.0.0.1", "::1", or "localhost".
307    pub fn is_localhost(&self) -> bool {
308        match validate_bind_address(&self.bind_address) {
309            Ok(ip) => ip.is_loopback(),
310            Err(_) => {
311                // Also check for "localhost" string directly
312                self.bind_address.eq_ignore_ascii_case("localhost")
313            }
314        }
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_default_config() {
324        let config = Config::default();
325        assert_eq!(config.version, 1);
326        assert_eq!(config.opencode_web_port, 3000);
327        assert_eq!(config.bind, "localhost");
328        assert!(config.auto_restart);
329        assert_eq!(config.boot_mode, "user");
330        assert_eq!(config.restart_retries, 3);
331        assert_eq!(config.restart_delay, 5);
332        assert!(config.auth_username.is_none());
333        assert!(config.auth_password.is_none());
334        assert!(config.container_env.is_empty());
335        // Security fields
336        assert_eq!(config.bind_address, "127.0.0.1");
337        assert!(!config.trust_proxy);
338        assert!(!config.allow_unauthenticated_network);
339        assert_eq!(config.rate_limit_attempts, 5);
340        assert_eq!(config.rate_limit_window_seconds, 60);
341        assert!(config.users.is_empty());
342        assert_eq!(config.mounts, default_mounts());
343    }
344
345    #[test]
346    fn test_serialize_deserialize_roundtrip() {
347        let config = Config::default();
348        let json = serde_json::to_string(&config).unwrap();
349        let parsed: Config = serde_json::from_str(&json).unwrap();
350        assert_eq!(config, parsed);
351    }
352
353    #[test]
354    fn test_deserialize_with_missing_optional_fields() {
355        let json = r#"{"version": 1}"#;
356        let config: Config = serde_json::from_str(json).unwrap();
357        assert_eq!(config.version, 1);
358        assert_eq!(config.opencode_web_port, 3000);
359        assert_eq!(config.bind, "localhost");
360        assert!(config.auto_restart);
361        assert_eq!(config.boot_mode, "user");
362        assert_eq!(config.restart_retries, 3);
363        assert_eq!(config.restart_delay, 5);
364        assert!(config.auth_username.is_none());
365        assert!(config.auth_password.is_none());
366        assert!(config.container_env.is_empty());
367        // Security fields should have defaults
368        assert_eq!(config.bind_address, "127.0.0.1");
369        assert!(!config.trust_proxy);
370        assert!(!config.allow_unauthenticated_network);
371        assert_eq!(config.rate_limit_attempts, 5);
372        assert_eq!(config.rate_limit_window_seconds, 60);
373        assert!(config.users.is_empty());
374    }
375
376    #[test]
377    fn test_serialize_deserialize_roundtrip_with_service_fields() {
378        let config = Config {
379            version: 1,
380            opencode_web_port: 9000,
381            bind: "0.0.0.0".to_string(),
382            auto_restart: false,
383            boot_mode: "system".to_string(),
384            restart_retries: 5,
385            restart_delay: 10,
386            auth_username: None,
387            auth_password: None,
388            container_env: Vec::new(),
389            bind_address: "0.0.0.0".to_string(),
390            trust_proxy: true,
391            allow_unauthenticated_network: false,
392            rate_limit_attempts: 10,
393            rate_limit_window_seconds: 120,
394            users: vec!["admin".to_string()],
395            cockpit_port: 9090,
396            cockpit_enabled: true,
397            image_source: default_image_source(),
398            update_check: default_update_check(),
399            mounts: Vec::new(),
400        };
401        let json = serde_json::to_string(&config).unwrap();
402        let parsed: Config = serde_json::from_str(&json).unwrap();
403        assert_eq!(config, parsed);
404        assert_eq!(parsed.boot_mode, "system");
405        assert_eq!(parsed.restart_retries, 5);
406        assert_eq!(parsed.restart_delay, 10);
407        assert_eq!(parsed.bind_address, "0.0.0.0");
408        assert!(parsed.trust_proxy);
409        assert_eq!(parsed.rate_limit_attempts, 10);
410        assert_eq!(parsed.users, vec!["admin"]);
411    }
412
413    #[test]
414    fn test_reject_unknown_fields() {
415        let json = r#"{"version": 1, "unknown_field": "value"}"#;
416        let result: Result<Config, _> = serde_json::from_str(json);
417        assert!(result.is_err());
418    }
419
420    #[test]
421    fn test_serialize_deserialize_roundtrip_with_auth_fields() {
422        let config = Config {
423            auth_username: Some("admin".to_string()),
424            auth_password: Some("secret123".to_string()),
425            container_env: vec!["FOO=bar".to_string(), "BAZ=qux".to_string()],
426            ..Config::default()
427        };
428        let json = serde_json::to_string(&config).unwrap();
429        let parsed: Config = serde_json::from_str(&json).unwrap();
430        assert_eq!(config, parsed);
431        assert_eq!(parsed.auth_username, Some("admin".to_string()));
432        assert_eq!(parsed.auth_password, Some("secret123".to_string()));
433        assert_eq!(parsed.container_env, vec!["FOO=bar", "BAZ=qux"]);
434    }
435
436    #[test]
437    fn test_has_required_auth_returns_false_when_both_none() {
438        let config = Config::default();
439        assert!(!config.has_required_auth());
440    }
441
442    #[test]
443    fn test_has_required_auth_returns_false_when_username_none() {
444        let config = Config {
445            auth_username: None,
446            auth_password: Some("secret".to_string()),
447            ..Config::default()
448        };
449        assert!(!config.has_required_auth());
450    }
451
452    #[test]
453    fn test_has_required_auth_returns_false_when_password_none() {
454        let config = Config {
455            auth_username: Some("admin".to_string()),
456            auth_password: None,
457            ..Config::default()
458        };
459        assert!(!config.has_required_auth());
460    }
461
462    #[test]
463    fn test_has_required_auth_returns_false_when_username_empty() {
464        let config = Config {
465            auth_username: Some(String::new()),
466            auth_password: Some("secret".to_string()),
467            ..Config::default()
468        };
469        assert!(!config.has_required_auth());
470    }
471
472    #[test]
473    fn test_has_required_auth_returns_false_when_password_empty() {
474        let config = Config {
475            auth_username: Some("admin".to_string()),
476            auth_password: Some(String::new()),
477            ..Config::default()
478        };
479        assert!(!config.has_required_auth());
480    }
481
482    #[test]
483    fn test_has_required_auth_returns_true_when_both_set() {
484        let config = Config {
485            auth_username: Some("admin".to_string()),
486            auth_password: Some("secret123".to_string()),
487            ..Config::default()
488        };
489        assert!(config.has_required_auth());
490    }
491
492    // Tests for validate_bind_address
493
494    #[test]
495    fn test_validate_bind_address_ipv4_localhost() {
496        let result = validate_bind_address("127.0.0.1");
497        assert!(result.is_ok());
498        let ip = result.unwrap();
499        assert!(ip.is_loopback());
500    }
501
502    #[test]
503    fn test_validate_bind_address_ipv4_all_interfaces() {
504        let result = validate_bind_address("0.0.0.0");
505        assert!(result.is_ok());
506        let ip = result.unwrap();
507        assert!(ip.is_unspecified());
508    }
509
510    #[test]
511    fn test_validate_bind_address_ipv6_localhost() {
512        let result = validate_bind_address("::1");
513        assert!(result.is_ok());
514        let ip = result.unwrap();
515        assert!(ip.is_loopback());
516    }
517
518    #[test]
519    fn test_validate_bind_address_ipv6_all_interfaces() {
520        let result = validate_bind_address("::");
521        assert!(result.is_ok());
522        let ip = result.unwrap();
523        assert!(ip.is_unspecified());
524    }
525
526    #[test]
527    fn test_validate_bind_address_localhost_string() {
528        let result = validate_bind_address("localhost");
529        assert!(result.is_ok());
530        assert_eq!(result.unwrap().to_string(), "127.0.0.1");
531    }
532
533    #[test]
534    fn test_validate_bind_address_localhost_case_insensitive() {
535        let result = validate_bind_address("LOCALHOST");
536        assert!(result.is_ok());
537        assert_eq!(result.unwrap().to_string(), "127.0.0.1");
538    }
539
540    #[test]
541    fn test_validate_bind_address_bracketed_ipv6() {
542        let result = validate_bind_address("[::1]");
543        assert!(result.is_ok());
544        assert!(result.unwrap().is_loopback());
545    }
546
547    #[test]
548    fn test_validate_bind_address_invalid() {
549        let result = validate_bind_address("not-an-ip");
550        assert!(result.is_err());
551        assert!(result.unwrap_err().contains("Invalid IP address"));
552    }
553
554    #[test]
555    fn test_validate_bind_address_whitespace() {
556        let result = validate_bind_address("  127.0.0.1  ");
557        assert!(result.is_ok());
558    }
559
560    // Tests for is_network_exposed
561
562    #[test]
563    fn test_is_network_exposed_ipv4_all() {
564        let config = Config {
565            bind_address: "0.0.0.0".to_string(),
566            ..Config::default()
567        };
568        assert!(config.is_network_exposed());
569    }
570
571    #[test]
572    fn test_is_network_exposed_ipv6_all() {
573        let config = Config {
574            bind_address: "::".to_string(),
575            ..Config::default()
576        };
577        assert!(config.is_network_exposed());
578    }
579
580    #[test]
581    fn test_is_network_exposed_localhost_false() {
582        let config = Config::default();
583        assert!(!config.is_network_exposed());
584    }
585
586    #[test]
587    fn test_is_network_exposed_ipv6_localhost_false() {
588        let config = Config {
589            bind_address: "::1".to_string(),
590            ..Config::default()
591        };
592        assert!(!config.is_network_exposed());
593    }
594
595    // Tests for is_localhost
596
597    #[test]
598    fn test_is_localhost_ipv4() {
599        let config = Config {
600            bind_address: "127.0.0.1".to_string(),
601            ..Config::default()
602        };
603        assert!(config.is_localhost());
604    }
605
606    #[test]
607    fn test_is_localhost_ipv6() {
608        let config = Config {
609            bind_address: "::1".to_string(),
610            ..Config::default()
611        };
612        assert!(config.is_localhost());
613    }
614
615    #[test]
616    fn test_is_localhost_string() {
617        let config = Config {
618            bind_address: "localhost".to_string(),
619            ..Config::default()
620        };
621        assert!(config.is_localhost());
622    }
623
624    #[test]
625    fn test_is_localhost_all_interfaces_false() {
626        let config = Config {
627            bind_address: "0.0.0.0".to_string(),
628            ..Config::default()
629        };
630        assert!(!config.is_localhost());
631    }
632
633    // Tests for security fields serialization
634
635    #[test]
636    fn test_serialize_deserialize_with_security_fields() {
637        let config = Config {
638            bind_address: "0.0.0.0".to_string(),
639            trust_proxy: true,
640            allow_unauthenticated_network: true,
641            rate_limit_attempts: 10,
642            rate_limit_window_seconds: 120,
643            users: vec!["admin".to_string(), "developer".to_string()],
644            ..Config::default()
645        };
646        let json = serde_json::to_string(&config).unwrap();
647        let parsed: Config = serde_json::from_str(&json).unwrap();
648        assert_eq!(config, parsed);
649        assert_eq!(parsed.bind_address, "0.0.0.0");
650        assert!(parsed.trust_proxy);
651        assert!(parsed.allow_unauthenticated_network);
652        assert_eq!(parsed.rate_limit_attempts, 10);
653        assert_eq!(parsed.rate_limit_window_seconds, 120);
654        assert_eq!(parsed.users, vec!["admin", "developer"]);
655    }
656
657    // Tests for Cockpit fields
658
659    #[test]
660    fn test_default_config_cockpit_fields() {
661        let config = Config::default();
662        assert_eq!(config.cockpit_port, 9090);
663        // cockpit_enabled defaults to false (requires Linux host)
664        assert!(!config.cockpit_enabled);
665    }
666
667    #[test]
668    fn test_serialize_deserialize_with_cockpit_fields() {
669        let config = Config {
670            cockpit_port: 9091,
671            cockpit_enabled: false,
672            ..Config::default()
673        };
674        let json = serde_json::to_string(&config).unwrap();
675        let parsed: Config = serde_json::from_str(&json).unwrap();
676        assert_eq!(parsed.cockpit_port, 9091);
677        assert!(!parsed.cockpit_enabled);
678    }
679
680    #[test]
681    fn test_cockpit_fields_default_on_missing() {
682        // Old configs without cockpit fields should get defaults
683        let json = r#"{"version": 1}"#;
684        let config: Config = serde_json::from_str(json).unwrap();
685        assert_eq!(config.cockpit_port, 9090);
686        // cockpit_enabled defaults to false (requires Linux host)
687        assert!(!config.cockpit_enabled);
688    }
689
690    // Tests for image_source and update_check fields
691
692    #[test]
693    fn test_default_config_image_fields() {
694        let config = Config::default();
695        assert_eq!(config.image_source, "prebuilt");
696        assert_eq!(config.update_check, "always");
697    }
698
699    #[test]
700    fn test_serialize_deserialize_with_image_fields() {
701        let config = Config {
702            image_source: "build".to_string(),
703            update_check: "never".to_string(),
704            ..Config::default()
705        };
706        let json = serde_json::to_string(&config).unwrap();
707        let parsed: Config = serde_json::from_str(&json).unwrap();
708        assert_eq!(parsed.image_source, "build");
709        assert_eq!(parsed.update_check, "never");
710    }
711
712    #[test]
713    fn test_image_fields_default_on_missing() {
714        // Old configs without image fields should get defaults
715        let json = r#"{"version": 1}"#;
716        let config: Config = serde_json::from_str(json).unwrap();
717        assert_eq!(config.image_source, "prebuilt");
718        assert_eq!(config.update_check, "always");
719    }
720
721    // Tests for mounts field
722
723    #[test]
724    fn test_default_config_mounts_field() {
725        let config = Config::default();
726        assert_eq!(config.mounts, default_mounts());
727    }
728
729    #[test]
730    fn test_serialize_deserialize_with_mounts() {
731        let config = Config {
732            mounts: vec![
733                "/home/user/data:/home/opencoder/workspace/data".to_string(),
734                "/home/user/config:/etc/app:ro".to_string(),
735            ],
736            ..Config::default()
737        };
738        let json = serde_json::to_string(&config).unwrap();
739        let parsed: Config = serde_json::from_str(&json).unwrap();
740        assert_eq!(parsed.mounts.len(), 2);
741        assert_eq!(
742            parsed.mounts[0],
743            "/home/user/data:/home/opencoder/workspace/data"
744        );
745        assert_eq!(parsed.mounts[1], "/home/user/config:/etc/app:ro");
746    }
747
748    #[test]
749    fn test_mounts_field_default_on_missing() {
750        // Old configs without mounts field should get default mounts
751        let json = r#"{"version": 1}"#;
752        let config: Config = serde_json::from_str(json).unwrap();
753        assert_eq!(config.mounts, default_mounts());
754    }
755}