@opencode-cloud/core 4.2.0 → 4.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "opencode-cloud-core"
3
- version = "4.2.0"
3
+ version = "4.2.2"
4
4
  edition = "2024"
5
5
  rust-version = "1.88"
6
6
  license = "MIT"
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
10
10
 
11
11
  > [!WARNING]
12
- > This project is a work in progress and evolving rapidly. Use with caution.
12
+ > This tool is still a work in progress and is rapidly evolving. Expect frequent updates and breaking changes. Follow updates at https://github.com/pRizz/opencode-cloud. Stability will be announced at some point. Use with caution.
13
13
 
14
14
  A production-ready toolkit for deploying and managing [opencode](https://github.com/anomalyco/opencode) as a persistent cloud service, **sandboxed inside a Docker container** for isolation and security.
15
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode-cloud/core",
3
- "version": "4.2.0",
3
+ "version": "4.2.2",
4
4
  "description": "Core NAPI bindings for opencode-cloud (internal package)",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/src/config/mod.rs CHANGED
@@ -14,8 +14,9 @@ use std::path::PathBuf;
14
14
  use anyhow::{Context, Result};
15
15
  use jsonc_parser::parse_to_serde_value;
16
16
 
17
+ use crate::docker::mount::ParsedMount;
17
18
  pub use paths::{get_config_dir, get_config_path, get_data_dir, get_hosts_path, get_pid_path};
18
- pub use schema::{Config, validate_bind_address};
19
+ pub use schema::{Config, default_mounts, validate_bind_address};
19
20
  pub use validation::{
20
21
  ValidationError, ValidationWarning, display_validation_error, display_validation_warning,
21
22
  validate_config,
@@ -75,6 +76,7 @@ pub fn load_config() -> Result<Config> {
75
76
  config_path.display()
76
77
  );
77
78
  let config = Config::default();
79
+ ensure_default_mount_dirs(&config)?;
78
80
  save_config(&config)?;
79
81
  return Ok(config);
80
82
  }
@@ -98,13 +100,37 @@ pub fn load_config() -> Result<Config> {
98
100
  }
99
101
 
100
102
  // Deserialize into Config struct (deny_unknown_fields will reject unknown keys)
101
- let config: Config = serde_json::from_value(parsed_value).with_context(|| {
103
+ let mut config: Config = serde_json::from_value(parsed_value).with_context(|| {
102
104
  format!(
103
105
  "Invalid configuration in {}. Check for unknown fields or invalid values.",
104
106
  config_path.display()
105
107
  )
106
108
  })?;
107
109
 
110
+ let mut removed_shadowing_mounts = false;
111
+ config.mounts.retain(|mount_str| {
112
+ let parsed = match ParsedMount::parse(mount_str) {
113
+ Ok(parsed) => parsed,
114
+ Err(_) => return true,
115
+ };
116
+ if parsed.container_path == "/opt/opencode"
117
+ || parsed.container_path.starts_with("/opt/opencode/")
118
+ {
119
+ removed_shadowing_mounts = true;
120
+ tracing::warn!(
121
+ "Skipping bind mount that overrides opencode binaries: {}",
122
+ mount_str
123
+ );
124
+ return false;
125
+ }
126
+ true
127
+ });
128
+
129
+ if removed_shadowing_mounts {
130
+ tracing::info!("Removed bind mounts that shadow /opt/opencode");
131
+ }
132
+
133
+ ensure_default_mount_dirs(&config)?;
108
134
  Ok(config)
109
135
  }
110
136
 
@@ -141,6 +167,36 @@ pub fn save_config(config: &Config) -> Result<()> {
141
167
  Ok(())
142
168
  }
143
169
 
170
+ fn ensure_default_mount_dirs(config: &Config) -> Result<()> {
171
+ let defaults = default_mounts();
172
+ if defaults.is_empty() {
173
+ return Ok(());
174
+ }
175
+
176
+ for mount_str in &config.mounts {
177
+ if !defaults.contains(mount_str) {
178
+ continue;
179
+ }
180
+ let parsed = crate::docker::mount::ParsedMount::parse(mount_str)
181
+ .with_context(|| format!("Invalid default mount configured: {mount_str}"))?;
182
+ let path = parsed.host_path.as_path();
183
+ if path.exists() {
184
+ if !path.is_dir() {
185
+ return Err(anyhow::anyhow!(
186
+ "Default mount path is not a directory: {}",
187
+ path.display()
188
+ ));
189
+ }
190
+ continue;
191
+ }
192
+ fs::create_dir_all(path)
193
+ .with_context(|| format!("Failed to create mount directory: {}", path.display()))?;
194
+ tracing::info!("Created mount directory: {}", path.display());
195
+ }
196
+
197
+ Ok(())
198
+ }
199
+
144
200
  #[cfg(test)]
145
201
  mod tests {
146
202
  use super::*;
@@ -2,6 +2,10 @@
2
2
  //!
3
3
  //! Defines the structure and defaults for the config.json file.
4
4
 
5
+ use crate::docker::volume::{
6
+ MOUNT_CACHE, MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, MOUNT_STATE,
7
+ };
8
+ use directories::BaseDirs;
5
9
  use serde::{Deserialize, Serialize};
6
10
  use std::net::{IpAddr, Ipv4Addr};
7
11
  /// Main configuration structure for opencode-cloud
@@ -117,7 +121,7 @@ pub struct Config {
117
121
 
118
122
  /// Bind mounts to apply when starting the container
119
123
  /// Format: ["/host/path:/container/path", "/host:/mnt:ro"]
120
- #[serde(default)]
124
+ #[serde(default = "default_mounts")]
121
125
  pub mounts: Vec<String>,
122
126
  }
123
127
 
@@ -173,6 +177,27 @@ fn default_update_check() -> String {
173
177
  "always".to_string()
174
178
  }
175
179
 
180
+ pub 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
+
176
201
  /// Validate and parse a bind address string
177
202
  ///
178
203
  /// Accepts:
@@ -225,7 +250,7 @@ impl Default for Config {
225
250
  cockpit_enabled: default_cockpit_enabled(),
226
251
  image_source: default_image_source(),
227
252
  update_check: default_update_check(),
228
- mounts: Vec::new(),
253
+ mounts: default_mounts(),
229
254
  }
230
255
  }
231
256
  }
@@ -309,7 +334,7 @@ mod tests {
309
334
  assert_eq!(config.rate_limit_attempts, 5);
310
335
  assert_eq!(config.rate_limit_window_seconds, 60);
311
336
  assert!(config.users.is_empty());
312
- assert!(config.mounts.is_empty());
337
+ assert_eq!(config.mounts, default_mounts());
313
338
  }
314
339
 
315
340
  #[test]
@@ -693,7 +718,7 @@ mod tests {
693
718
  #[test]
694
719
  fn test_default_config_mounts_field() {
695
720
  let config = Config::default();
696
- assert!(config.mounts.is_empty());
721
+ assert_eq!(config.mounts, default_mounts());
697
722
  }
698
723
 
699
724
  #[test]
@@ -717,9 +742,9 @@ mod tests {
717
742
 
718
743
  #[test]
719
744
  fn test_mounts_field_default_on_missing() {
720
- // Old configs without mounts field should get empty vec
745
+ // Old configs without mounts field should get default mounts
721
746
  let json = r#"{"version": 1}"#;
722
747
  let config: Config = serde_json::from_str(json).unwrap();
723
- assert!(config.mounts.is_empty());
748
+ assert_eq!(config.mounts, default_mounts());
724
749
  }
725
750
  }
@@ -164,6 +164,7 @@ RUN mkdir -p \
164
164
  /home/opencode/.config \
165
165
  /home/opencode/.local/bin \
166
166
  /home/opencode/.local/share \
167
+ /home/opencode/.local/state \
167
168
  /home/opencode/.cache \
168
169
  /home/opencode/workspace
169
170
 
@@ -529,16 +530,15 @@ RUN OPENCODE_COMMIT="c7fb116c1cf59a76b184f842ed3eb0113d93196b" \
529
530
  && bun run build-single-ui \
530
531
  && rm -rf /home/opencode/.bun/install/cache /home/opencode/.bun/cache /home/opencode/.cache/bun \
531
532
  && cd /tmp/opencode-repo \
532
- && mkdir -p /home/opencode/.local/share/opencode/bin \
533
- && mkdir -p /home/opencode/.local/share/opencode/ui \
534
- && cp /tmp/opencode-repo/packages/opencode/dist/opencode-*/bin/opencode /home/opencode/.local/share/opencode/bin/opencode \
535
- && cp -R /tmp/opencode-repo/packages/opencode/dist/opencode-*/ui/. /home/opencode/.local/share/opencode/ui/ \
536
- && chown -R opencode:opencode /home/opencode/.local/share/opencode \
537
- && chmod +x /home/opencode/.local/share/opencode/bin/opencode \
538
- && /home/opencode/.local/share/opencode/bin/opencode --version
533
+ && sudo mkdir -p /opt/opencode/bin /opt/opencode/ui \
534
+ && sudo cp /tmp/opencode-repo/packages/opencode/dist/opencode-*/bin/opencode /opt/opencode/bin/opencode \
535
+ && sudo cp -R /tmp/opencode-repo/packages/opencode/dist/opencode-*/ui/. /opt/opencode/ui/ \
536
+ && sudo chown -R opencode:opencode /opt/opencode \
537
+ && sudo chmod +x /opt/opencode/bin/opencode \
538
+ && /opt/opencode/bin/opencode --version
539
539
 
540
540
  # Add opencode to PATH
541
- ENV PATH="/home/opencode/.local/share/opencode/bin:${PATH}"
541
+ ENV PATH="/opt/opencode/bin:${PATH}"
542
542
 
543
543
  # -----------------------------------------------------------------------------
544
544
  # opencode-broker Installation
@@ -651,10 +651,10 @@ RUN printf '%s\n' \
651
651
  'Type=simple' \
652
652
  'User=opencode' \
653
653
  'WorkingDirectory=/home/opencode/workspace' \
654
- 'ExecStart=/home/opencode/.local/share/opencode/bin/opencode web --port 3000 --hostname 0.0.0.0' \
654
+ 'ExecStart=/opt/opencode/bin/opencode web --port 3000 --hostname 0.0.0.0' \
655
655
  'Restart=always' \
656
656
  'RestartSec=5' \
657
- 'Environment=PATH=/home/opencode/.local/share/opencode/bin:/home/opencode/.local/bin:/home/opencode/.cargo/bin:/home/opencode/.local/share/mise/shims:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' \
657
+ 'Environment=PATH=/opt/opencode/bin:/home/opencode/.local/bin:/home/opencode/.cargo/bin:/home/opencode/.local/share/mise/shims:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' \
658
658
  '' \
659
659
  '[Install]' \
660
660
  'WantedBy=multi-user.target' \
@@ -700,7 +700,7 @@ RUN printf '%s\n' \
700
700
  ' install -d -m 0755 /run/opencode' \
701
701
  ' /usr/local/bin/opencode-broker &' \
702
702
  ' # Use runuser to switch to opencode user without password prompt' \
703
- ' exec runuser -u opencode -- sh -lc "cd /home/opencode/workspace && /home/opencode/.local/share/opencode/bin/opencode web --port 3000 --hostname 0.0.0.0"' \
703
+ ' exec runuser -u opencode -- sh -lc "cd /home/opencode/workspace && /opt/opencode/bin/opencode web --port 3000 --hostname 0.0.0.0"' \
704
704
  'fi' \
705
705
  > /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh
706
706
 
@@ -6,7 +6,8 @@
6
6
  use super::dockerfile::{IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
7
7
  use super::mount::ParsedMount;
8
8
  use super::volume::{
9
- MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, VOLUME_CONFIG, VOLUME_PROJECTS, VOLUME_SESSION,
9
+ MOUNT_CACHE, MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, MOUNT_STATE, VOLUME_CACHE,
10
+ VOLUME_CONFIG, VOLUME_PROJECTS, VOLUME_SESSION, VOLUME_STATE,
10
11
  };
11
12
  use super::{DockerClient, DockerError};
12
13
  use bollard::container::{
@@ -16,7 +17,7 @@ use bollard::container::{
16
17
  use bollard::service::{
17
18
  HostConfig, Mount, MountPointTypeEnum, MountTypeEnum, PortBinding, PortMap,
18
19
  };
19
- use std::collections::HashMap;
20
+ use std::collections::{HashMap, HashSet};
20
21
  use tracing::debug;
21
22
 
22
23
  /// Default container name
@@ -25,6 +26,11 @@ pub const CONTAINER_NAME: &str = "opencode-cloud-sandbox";
25
26
  /// Default port for opencode web UI
26
27
  pub const OPENCODE_WEB_PORT: u16 = 3000;
27
28
 
29
+ fn has_env_key(env: &[String], key: &str) -> bool {
30
+ let prefix = format!("{key}=");
31
+ env.iter().any(|entry| entry.starts_with(&prefix))
32
+ }
33
+
28
34
  /// Create the opencode container with volume mounts
29
35
  ///
30
36
  /// Does not start the container - use start_container after creation.
@@ -85,30 +91,36 @@ pub async fn create_container(
85
91
  )));
86
92
  }
87
93
 
88
- // Create volume mounts
89
- let mut mounts = vec![
90
- Mount {
91
- target: Some(MOUNT_SESSION.to_string()),
92
- source: Some(VOLUME_SESSION.to_string()),
93
- typ: Some(MountTypeEnum::VOLUME),
94
- read_only: Some(false),
95
- ..Default::default()
96
- },
97
- Mount {
98
- target: Some(MOUNT_PROJECTS.to_string()),
99
- source: Some(VOLUME_PROJECTS.to_string()),
100
- typ: Some(MountTypeEnum::VOLUME),
101
- read_only: Some(false),
102
- ..Default::default()
103
- },
104
- Mount {
105
- target: Some(MOUNT_CONFIG.to_string()),
106
- source: Some(VOLUME_CONFIG.to_string()),
94
+ let mut bind_targets = HashSet::new();
95
+ if let Some(ref user_mounts) = bind_mounts {
96
+ for parsed in user_mounts {
97
+ bind_targets.insert(parsed.container_path.clone());
98
+ }
99
+ }
100
+
101
+ // Create volume mounts (skip if overridden by bind mounts)
102
+ let mut mounts = Vec::new();
103
+ let mut add_volume_mount = |target: &str, source: &str| {
104
+ if bind_targets.contains(target) {
105
+ tracing::trace!(
106
+ "Skipping volume mount for {} (overridden by bind mount)",
107
+ target
108
+ );
109
+ return;
110
+ }
111
+ mounts.push(Mount {
112
+ target: Some(target.to_string()),
113
+ source: Some(source.to_string()),
107
114
  typ: Some(MountTypeEnum::VOLUME),
108
115
  read_only: Some(false),
109
116
  ..Default::default()
110
- },
111
- ];
117
+ });
118
+ };
119
+ add_volume_mount(MOUNT_SESSION, VOLUME_SESSION);
120
+ add_volume_mount(MOUNT_STATE, VOLUME_STATE);
121
+ add_volume_mount(MOUNT_CACHE, VOLUME_CACHE);
122
+ add_volume_mount(MOUNT_PROJECTS, VOLUME_PROJECTS);
123
+ add_volume_mount(MOUNT_CONFIG, VOLUME_CONFIG);
112
124
 
113
125
  // Add user-defined bind mounts from config/CLI
114
126
  if let Some(ref user_mounts) = bind_mounts {
@@ -189,14 +201,24 @@ pub async fn create_container(
189
201
  };
190
202
 
191
203
  // Build environment variables
204
+ let mut env = env_vars.unwrap_or_default();
205
+ if !has_env_key(&env, "XDG_DATA_HOME") {
206
+ env.push("XDG_DATA_HOME=/home/opencode/.local/share".to_string());
207
+ }
208
+ if !has_env_key(&env, "XDG_STATE_HOME") {
209
+ env.push("XDG_STATE_HOME=/home/opencode/.local/state".to_string());
210
+ }
211
+ if !has_env_key(&env, "XDG_CONFIG_HOME") {
212
+ env.push("XDG_CONFIG_HOME=/home/opencode/.config".to_string());
213
+ }
214
+ if !has_env_key(&env, "XDG_CACHE_HOME") {
215
+ env.push("XDG_CACHE_HOME=/home/opencode/.cache".to_string());
216
+ }
192
217
  // Add USE_SYSTEMD=1 when Cockpit is enabled to tell entrypoint to use systemd
193
- let final_env = if cockpit_enabled_val {
194
- let mut env = env_vars.unwrap_or_default();
218
+ if cockpit_enabled_val && !has_env_key(&env, "USE_SYSTEMD") {
195
219
  env.push("USE_SYSTEMD=1".to_string());
196
- Some(env)
197
- } else {
198
- env_vars
199
- };
220
+ }
221
+ let final_env = if env.is_empty() { None } else { Some(env) };
200
222
 
201
223
  // Create container config
202
224
  let config = Config {
package/src/docker/mod.rs CHANGED
@@ -60,8 +60,9 @@ pub use users::{
60
60
 
61
61
  // Volume management
62
62
  pub use volume::{
63
- MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, VOLUME_CONFIG, VOLUME_NAMES, VOLUME_PROJECTS,
64
- VOLUME_SESSION, ensure_volumes_exist, remove_all_volumes, remove_volume, volume_exists,
63
+ MOUNT_CACHE, MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, MOUNT_STATE, VOLUME_CACHE,
64
+ VOLUME_CONFIG, VOLUME_NAMES, VOLUME_PROJECTS, VOLUME_SESSION, VOLUME_STATE,
65
+ ensure_volumes_exist, remove_all_volumes, remove_volume, volume_exists,
65
66
  };
66
67
 
67
68
  // Bind mount parsing and validation
@@ -11,6 +11,12 @@ use tracing::debug;
11
11
  /// Volume name for opencode data
12
12
  pub const VOLUME_SESSION: &str = "opencode-data";
13
13
 
14
+ /// Volume name for opencode state
15
+ pub const VOLUME_STATE: &str = "opencode-state";
16
+
17
+ /// Volume name for opencode cache
18
+ pub const VOLUME_CACHE: &str = "opencode-cache";
19
+
14
20
  /// Volume name for project files
15
21
  pub const VOLUME_PROJECTS: &str = "opencode-workspace";
16
22
 
@@ -18,16 +24,28 @@ pub const VOLUME_PROJECTS: &str = "opencode-workspace";
18
24
  pub const VOLUME_CONFIG: &str = "opencode-config";
19
25
 
20
26
  /// All volume names as array for iteration
21
- pub const VOLUME_NAMES: [&str; 3] = [VOLUME_SESSION, VOLUME_PROJECTS, VOLUME_CONFIG];
27
+ pub const VOLUME_NAMES: [&str; 5] = [
28
+ VOLUME_SESSION,
29
+ VOLUME_STATE,
30
+ VOLUME_CACHE,
31
+ VOLUME_PROJECTS,
32
+ VOLUME_CONFIG,
33
+ ];
22
34
 
23
35
  /// Mount point for opencode data inside container
24
- pub const MOUNT_SESSION: &str = "/home/opencode/.local/share";
36
+ pub const MOUNT_SESSION: &str = "/home/opencode/.local/share/opencode";
37
+
38
+ /// Mount point for opencode state inside container
39
+ pub const MOUNT_STATE: &str = "/home/opencode/.local/state/opencode";
40
+
41
+ /// Mount point for opencode cache inside container
42
+ pub const MOUNT_CACHE: &str = "/home/opencode/.cache/opencode";
25
43
 
26
44
  /// Mount point for project files inside container
27
45
  pub const MOUNT_PROJECTS: &str = "/home/opencode/workspace";
28
46
 
29
47
  /// Mount point for configuration inside container
30
- pub const MOUNT_CONFIG: &str = "/home/opencode/.config";
48
+ pub const MOUNT_CONFIG: &str = "/home/opencode/.config/opencode";
31
49
 
32
50
  /// Ensure all required volumes exist
33
51
  ///
@@ -123,22 +141,28 @@ mod tests {
123
141
  #[test]
124
142
  fn volume_constants_are_correct() {
125
143
  assert_eq!(VOLUME_SESSION, "opencode-data");
144
+ assert_eq!(VOLUME_STATE, "opencode-state");
145
+ assert_eq!(VOLUME_CACHE, "opencode-cache");
126
146
  assert_eq!(VOLUME_PROJECTS, "opencode-workspace");
127
147
  assert_eq!(VOLUME_CONFIG, "opencode-config");
128
148
  }
129
149
 
130
150
  #[test]
131
151
  fn volume_names_array_has_all_volumes() {
132
- assert_eq!(VOLUME_NAMES.len(), 3);
152
+ assert_eq!(VOLUME_NAMES.len(), 5);
133
153
  assert!(VOLUME_NAMES.contains(&VOLUME_SESSION));
154
+ assert!(VOLUME_NAMES.contains(&VOLUME_STATE));
155
+ assert!(VOLUME_NAMES.contains(&VOLUME_CACHE));
134
156
  assert!(VOLUME_NAMES.contains(&VOLUME_PROJECTS));
135
157
  assert!(VOLUME_NAMES.contains(&VOLUME_CONFIG));
136
158
  }
137
159
 
138
160
  #[test]
139
161
  fn mount_points_are_correct() {
140
- assert_eq!(MOUNT_SESSION, "/home/opencode/.local/share");
162
+ assert_eq!(MOUNT_SESSION, "/home/opencode/.local/share/opencode");
163
+ assert_eq!(MOUNT_STATE, "/home/opencode/.local/state/opencode");
164
+ assert_eq!(MOUNT_CACHE, "/home/opencode/.cache/opencode");
141
165
  assert_eq!(MOUNT_PROJECTS, "/home/opencode/workspace");
142
- assert_eq!(MOUNT_CONFIG, "/home/opencode/.config");
166
+ assert_eq!(MOUNT_CONFIG, "/home/opencode/.config/opencode");
143
167
  }
144
168
  }