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