@opencode-cloud/core 1.0.8 → 3.0.15

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.
@@ -0,0 +1,39 @@
1
+ # opencode-cloud-sandbox
2
+
3
+ Opinionated container image for AI-assisted coding with opencode.
4
+
5
+ ## What is included
6
+
7
+ - Ubuntu 24.04 (noble)
8
+ - Non-root user with passwordless sudo
9
+ - mise-managed runtimes (Node.js LTS, Python 3.12, Go 1.24)
10
+ - Rust toolchain via rustup
11
+ - Core CLI utilities (ripgrep, eza, jq, git, etc.)
12
+ - Cockpit web console for administration
13
+ - opencode preinstalled with the GSD plugin
14
+
15
+ ## Tags
16
+
17
+ - `latest`: Most recent published release
18
+ - `X.Y.Z`: Versioned releases (recommended for pinning)
19
+
20
+ ## Usage
21
+
22
+ Pull the image:
23
+
24
+ ```
25
+ docker pull ghcr.io/prizz/opencode-cloud-sandbox:latest
26
+ ```
27
+
28
+ Run the container:
29
+
30
+ ```
31
+ docker run --rm -it -p 3000:3000 -p 9090:9090 ghcr.io/prizz/opencode-cloud-sandbox:latest
32
+ ```
33
+
34
+ The opencode web UI is available at `http://localhost:3000`. Cockpit runs on `http://localhost:9090`.
35
+
36
+ ## Source
37
+
38
+ - Repository: https://github.com/pRizz/opencode-cloud
39
+ - Dockerfile: packages/core/src/docker/Dockerfile
@@ -4,12 +4,18 @@
4
4
  //! errors gracefully and provides clear error messages.
5
5
 
6
6
  use bollard::Docker;
7
+ use std::time::Duration;
7
8
 
8
9
  use super::error::DockerError;
10
+ use crate::host::{HostConfig, SshTunnel};
9
11
 
10
12
  /// Docker client wrapper with connection handling
11
13
  pub struct DockerClient {
12
14
  inner: Docker,
15
+ /// SSH tunnel for remote connections (kept alive for client lifetime)
16
+ _tunnel: Option<SshTunnel>,
17
+ /// Host name for remote connections (None = local)
18
+ host_name: Option<String>,
13
19
  }
14
20
 
15
21
  impl DockerClient {
@@ -21,7 +27,11 @@ impl DockerClient {
21
27
  let docker = Docker::connect_with_local_defaults()
22
28
  .map_err(|e| DockerError::Connection(e.to_string()))?;
23
29
 
24
- Ok(Self { inner: docker })
30
+ Ok(Self {
31
+ inner: docker,
32
+ _tunnel: None,
33
+ host_name: None,
34
+ })
25
35
  }
26
36
 
27
37
  /// Create client with custom timeout (in seconds)
@@ -31,9 +41,109 @@ impl DockerClient {
31
41
  pub fn with_timeout(timeout_secs: u64) -> Result<Self, DockerError> {
32
42
  let docker = Docker::connect_with_local_defaults()
33
43
  .map_err(|e| DockerError::Connection(e.to_string()))?
34
- .with_timeout(std::time::Duration::from_secs(timeout_secs));
44
+ .with_timeout(Duration::from_secs(timeout_secs));
35
45
 
36
- Ok(Self { inner: docker })
46
+ Ok(Self {
47
+ inner: docker,
48
+ _tunnel: None,
49
+ host_name: None,
50
+ })
51
+ }
52
+
53
+ /// Create client connecting to remote Docker daemon via SSH tunnel
54
+ ///
55
+ /// Establishes an SSH tunnel to the remote host and connects Bollard
56
+ /// to the forwarded local port.
57
+ ///
58
+ /// # Arguments
59
+ /// * `host` - Remote host configuration
60
+ /// * `host_name` - Name of the host (for display purposes)
61
+ pub async fn connect_remote(host: &HostConfig, host_name: &str) -> Result<Self, DockerError> {
62
+ // Create SSH tunnel
63
+ let tunnel = SshTunnel::new(host, host_name)
64
+ .map_err(|e| DockerError::Connection(format!("SSH tunnel failed: {e}")))?;
65
+
66
+ // Wait for tunnel to be ready with exponential backoff
67
+ tunnel
68
+ .wait_ready()
69
+ .await
70
+ .map_err(|e| DockerError::Connection(format!("SSH tunnel not ready: {e}")))?;
71
+
72
+ // Connect Bollard to the tunnel's local port
73
+ let docker_url = tunnel.docker_url();
74
+ tracing::debug!("Connecting to remote Docker via {}", docker_url);
75
+
76
+ // Retry connection with backoff (tunnel may need a moment)
77
+ let max_attempts = 3;
78
+ let mut last_err = None;
79
+
80
+ for attempt in 0..max_attempts {
81
+ if attempt > 0 {
82
+ let delay = Duration::from_millis(100 * 2u64.pow(attempt));
83
+ tracing::debug!("Retry attempt {} after {:?}", attempt + 1, delay);
84
+ tokio::time::sleep(delay).await;
85
+ }
86
+
87
+ match Docker::connect_with_http(&docker_url, 120, bollard::API_DEFAULT_VERSION) {
88
+ Ok(docker) => {
89
+ // Verify connection works
90
+ match docker.ping().await {
91
+ Ok(_) => {
92
+ tracing::info!("Connected to Docker on {} via SSH tunnel", host_name);
93
+ return Ok(Self {
94
+ inner: docker,
95
+ _tunnel: Some(tunnel),
96
+ host_name: Some(host_name.to_string()),
97
+ });
98
+ }
99
+ Err(e) => {
100
+ tracing::debug!("Ping failed: {}", e);
101
+ last_err = Some(e.to_string());
102
+ }
103
+ }
104
+ }
105
+ Err(e) => {
106
+ tracing::debug!("Connection failed: {}", e);
107
+ last_err = Some(e.to_string());
108
+ }
109
+ }
110
+ }
111
+
112
+ Err(DockerError::Connection(format!(
113
+ "Failed to connect to Docker on {}: {}",
114
+ host_name,
115
+ last_err.unwrap_or_else(|| "unknown error".to_string())
116
+ )))
117
+ }
118
+
119
+ /// Create remote client with custom timeout
120
+ pub async fn connect_remote_with_timeout(
121
+ host: &HostConfig,
122
+ host_name: &str,
123
+ timeout_secs: u64,
124
+ ) -> Result<Self, DockerError> {
125
+ let tunnel = SshTunnel::new(host, host_name)
126
+ .map_err(|e| DockerError::Connection(format!("SSH tunnel failed: {e}")))?;
127
+
128
+ tunnel
129
+ .wait_ready()
130
+ .await
131
+ .map_err(|e| DockerError::Connection(format!("SSH tunnel not ready: {e}")))?;
132
+
133
+ let docker_url = tunnel.docker_url();
134
+
135
+ let docker =
136
+ Docker::connect_with_http(&docker_url, timeout_secs, bollard::API_DEFAULT_VERSION)
137
+ .map_err(|e| DockerError::Connection(e.to_string()))?;
138
+
139
+ // Verify connection
140
+ docker.ping().await.map_err(DockerError::from)?;
141
+
142
+ Ok(Self {
143
+ inner: docker,
144
+ _tunnel: Some(tunnel),
145
+ host_name: Some(host_name.to_string()),
146
+ })
37
147
  }
38
148
 
39
149
  /// Verify connection to Docker daemon
@@ -57,6 +167,16 @@ impl DockerClient {
57
167
  Ok(version_str)
58
168
  }
59
169
 
170
+ /// Get the host name if this is a remote connection
171
+ pub fn host_name(&self) -> Option<&str> {
172
+ self.host_name.as_deref()
173
+ }
174
+
175
+ /// Check if this is a remote connection
176
+ pub fn is_remote(&self) -> bool {
177
+ self._tunnel.is_some()
178
+ }
179
+
60
180
  /// Access inner Bollard client for advanced operations
61
181
  pub fn inner(&self) -> &Docker {
62
182
  &self.inner
@@ -81,4 +201,13 @@ mod tests {
81
201
  let result = DockerClient::with_timeout(600);
82
202
  drop(result);
83
203
  }
204
+
205
+ #[test]
206
+ fn test_host_name_methods() {
207
+ // Local client has no host name
208
+ if let Ok(client) = DockerClient::new() {
209
+ assert!(client.host_name().is_none());
210
+ assert!(!client.is_remote());
211
+ }
212
+ }
84
213
  }
@@ -4,6 +4,7 @@
4
4
  //! Docker containers for the opencode-cloud service.
5
5
 
6
6
  use super::dockerfile::{IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
7
+ use super::mount::ParsedMount;
7
8
  use super::volume::{
8
9
  MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, VOLUME_CONFIG, VOLUME_PROJECTS, VOLUME_SESSION,
9
10
  };
@@ -12,7 +13,9 @@ use bollard::container::{
12
13
  Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
13
14
  StopContainerOptions,
14
15
  };
15
- use bollard::service::{HostConfig, Mount, MountTypeEnum, PortBinding, PortMap};
16
+ use bollard::service::{
17
+ HostConfig, Mount, MountPointTypeEnum, MountTypeEnum, PortBinding, PortMap,
18
+ };
16
19
  use std::collections::HashMap;
17
20
  use tracing::debug;
18
21
 
@@ -33,28 +36,38 @@ pub const OPENCODE_WEB_PORT: u16 = 3000;
33
36
  /// * `image` - Image to use (defaults to IMAGE_NAME_GHCR:IMAGE_TAG_DEFAULT)
34
37
  /// * `opencode_web_port` - Port to bind on host for opencode web UI (defaults to OPENCODE_WEB_PORT)
35
38
  /// * `env_vars` - Additional environment variables (optional)
39
+ /// * `bind_address` - IP address to bind on host (defaults to "127.0.0.1")
40
+ /// * `cockpit_port` - Port to bind on host for Cockpit (defaults to 9090)
41
+ /// * `cockpit_enabled` - Whether to enable Cockpit port mapping (defaults to true)
42
+ /// * `bind_mounts` - User-defined bind mounts from config and CLI flags (optional)
43
+ #[allow(clippy::too_many_arguments)]
36
44
  pub async fn create_container(
37
45
  client: &DockerClient,
38
46
  name: Option<&str>,
39
47
  image: Option<&str>,
40
48
  opencode_web_port: Option<u16>,
41
49
  env_vars: Option<Vec<String>>,
50
+ bind_address: Option<&str>,
51
+ cockpit_port: Option<u16>,
52
+ cockpit_enabled: Option<bool>,
53
+ bind_mounts: Option<Vec<ParsedMount>>,
42
54
  ) -> Result<String, DockerError> {
43
55
  let container_name = name.unwrap_or(CONTAINER_NAME);
44
56
  let default_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
45
57
  let image_name = image.unwrap_or(&default_image);
46
58
  let port = opencode_web_port.unwrap_or(OPENCODE_WEB_PORT);
59
+ let cockpit_port_val = cockpit_port.unwrap_or(9090);
60
+ let cockpit_enabled_val = cockpit_enabled.unwrap_or(true);
47
61
 
48
62
  debug!(
49
- "Creating container {} from image {} with port {}",
50
- container_name, image_name, port
63
+ "Creating container {} from image {} with port {} and cockpit_port {} (enabled: {})",
64
+ container_name, image_name, port, cockpit_port_val, cockpit_enabled_val
51
65
  );
52
66
 
53
67
  // Check if container already exists
54
68
  if container_exists(client, container_name).await? {
55
69
  return Err(DockerError::Container(format!(
56
- "Container '{}' already exists. Remove it first with 'occ stop --remove' or use a different name.",
57
- container_name
70
+ "Container '{container_name}' already exists. Remove it first with 'occ stop --remove' or use a different name."
58
71
  )));
59
72
  }
60
73
 
@@ -68,13 +81,12 @@ pub async fn create_container(
68
81
 
69
82
  if !super::image::image_exists(client, image_repo, image_tag).await? {
70
83
  return Err(DockerError::Container(format!(
71
- "Image '{}' not found. Run 'occ pull' first to download the image.",
72
- image_name
84
+ "Image '{image_name}' not found. Run 'occ pull' first to download the image."
73
85
  )));
74
86
  }
75
87
 
76
88
  // Create volume mounts
77
- let mounts = vec![
89
+ let mut mounts = vec![
78
90
  Mount {
79
91
  target: Some(MOUNT_SESSION.to_string()),
80
92
  source: Some(VOLUME_SESSION.to_string()),
@@ -98,26 +110,92 @@ pub async fn create_container(
98
110
  },
99
111
  ];
100
112
 
101
- // Create port bindings (localhost only for security)
113
+ // Add user-defined bind mounts from config/CLI
114
+ if let Some(ref user_mounts) = bind_mounts {
115
+ for parsed in user_mounts {
116
+ mounts.push(parsed.to_bollard_mount());
117
+ }
118
+ }
119
+
120
+ // Create port bindings (default to localhost for security)
121
+ let bind_addr = bind_address.unwrap_or("127.0.0.1");
102
122
  let mut port_bindings: PortMap = HashMap::new();
123
+
124
+ // opencode web port
103
125
  port_bindings.insert(
104
126
  "3000/tcp".to_string(),
105
127
  Some(vec![PortBinding {
106
- host_ip: Some("127.0.0.1".to_string()),
128
+ host_ip: Some(bind_addr.to_string()),
107
129
  host_port: Some(port.to_string()),
108
130
  }]),
109
131
  );
110
132
 
133
+ // Cockpit port (if enabled)
134
+ // Container always listens on 9090, map to host's configured port
135
+ if cockpit_enabled_val {
136
+ port_bindings.insert(
137
+ "9090/tcp".to_string(),
138
+ Some(vec![PortBinding {
139
+ host_ip: Some(bind_addr.to_string()),
140
+ host_port: Some(cockpit_port_val.to_string()),
141
+ }]),
142
+ );
143
+ }
144
+
111
145
  // Create exposed ports map
112
146
  let mut exposed_ports = HashMap::new();
113
147
  exposed_ports.insert("3000/tcp".to_string(), HashMap::new());
148
+ if cockpit_enabled_val {
149
+ exposed_ports.insert("9090/tcp".to_string(), HashMap::new());
150
+ }
114
151
 
115
152
  // Create host config
116
- let host_config = HostConfig {
117
- mounts: Some(mounts),
118
- port_bindings: Some(port_bindings),
119
- auto_remove: Some(false),
120
- ..Default::default()
153
+ // When Cockpit is enabled, add systemd-specific settings (requires Linux host)
154
+ // When Cockpit is disabled, use simpler tini-based config (works everywhere)
155
+ let host_config = if cockpit_enabled_val {
156
+ HostConfig {
157
+ mounts: Some(mounts),
158
+ port_bindings: Some(port_bindings),
159
+ auto_remove: Some(false),
160
+ // CAP_SYS_ADMIN required for systemd cgroup access
161
+ cap_add: Some(vec!["SYS_ADMIN".to_string()]),
162
+ // tmpfs for /run, /run/lock, and /tmp (required for systemd)
163
+ tmpfs: Some(HashMap::from([
164
+ ("/run".to_string(), "exec".to_string()),
165
+ ("/run/lock".to_string(), String::new()),
166
+ ("/tmp".to_string(), String::new()),
167
+ ])),
168
+ // cgroup mount (read-write for systemd)
169
+ binds: Some(vec!["/sys/fs/cgroup:/sys/fs/cgroup:rw".to_string()]),
170
+ // Use HOST cgroup namespace for systemd compatibility across Linux distros:
171
+ // - cgroups v2 (Amazon Linux 2023, Fedora 31+, Ubuntu 21.10+, Debian 11+): required
172
+ // - cgroups v1 (CentOS 7, Ubuntu 18.04, Debian 10): works fine
173
+ // - Docker Desktop (macOS/Windows VM): works fine
174
+ // Note: PRIVATE mode is more isolated but causes systemd to exit(255) on cgroups v2.
175
+ // Since we already use privileged mode, HOST namespace is acceptable.
176
+ cgroupns_mode: Some(bollard::models::HostConfigCgroupnsModeEnum::HOST),
177
+ // Privileged mode required for systemd to manage cgroups and system services
178
+ privileged: Some(true),
179
+ ..Default::default()
180
+ }
181
+ } else {
182
+ // Simple config for tini mode (works on macOS and Linux)
183
+ HostConfig {
184
+ mounts: Some(mounts),
185
+ port_bindings: Some(port_bindings),
186
+ auto_remove: Some(false),
187
+ ..Default::default()
188
+ }
189
+ };
190
+
191
+ // Build environment variables
192
+ // 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();
195
+ env.push("USE_SYSTEMD=1".to_string());
196
+ Some(env)
197
+ } else {
198
+ env_vars
121
199
  };
122
200
 
123
201
  // Create container config
@@ -126,7 +204,7 @@ pub async fn create_container(
126
204
  hostname: Some(CONTAINER_NAME.to_string()),
127
205
  working_dir: Some("/workspace".to_string()),
128
206
  exposed_ports: Some(exposed_ports),
129
- env: env_vars,
207
+ env: final_env,
130
208
  host_config: Some(host_config),
131
209
  ..Default::default()
132
210
  };
@@ -145,11 +223,10 @@ pub async fn create_container(
145
223
  let msg = e.to_string();
146
224
  if msg.contains("port is already allocated") || msg.contains("address already in use") {
147
225
  DockerError::Container(format!(
148
- "Port {} is already in use. Stop the service using that port or use a different port with --port.",
149
- port
226
+ "Port {port} is already in use. Stop the service using that port or use a different port with --port."
150
227
  ))
151
228
  } else {
152
- DockerError::Container(format!("Failed to create container: {}", e))
229
+ DockerError::Container(format!("Failed to create container: {e}"))
153
230
  }
154
231
  })?;
155
232
 
@@ -165,9 +242,7 @@ pub async fn start_container(client: &DockerClient, name: &str) -> Result<(), Do
165
242
  .inner()
166
243
  .start_container(name, None::<StartContainerOptions<String>>)
167
244
  .await
168
- .map_err(|e| {
169
- DockerError::Container(format!("Failed to start container {}: {}", name, e))
170
- })?;
245
+ .map_err(|e| DockerError::Container(format!("Failed to start container {name}: {e}")))?;
171
246
 
172
247
  debug!("Container {} started", name);
173
248
  Ok(())
@@ -198,9 +273,9 @@ pub async fn stop_container(
198
273
  // "container already stopped" is not an error
199
274
  if msg.contains("is not running") || msg.contains("304") {
200
275
  debug!("Container {} was already stopped", name);
201
- return DockerError::Container(format!("Container '{}' is not running", name));
276
+ return DockerError::Container(format!("Container '{name}' is not running"));
202
277
  }
203
- DockerError::Container(format!("Failed to stop container {}: {}", name, e))
278
+ DockerError::Container(format!("Failed to stop container {name}: {e}"))
204
279
  })?;
205
280
 
206
281
  debug!("Container {} stopped", name);
@@ -230,9 +305,7 @@ pub async fn remove_container(
230
305
  .inner()
231
306
  .remove_container(name, Some(options))
232
307
  .await
233
- .map_err(|e| {
234
- DockerError::Container(format!("Failed to remove container {}: {}", name, e))
235
- })?;
308
+ .map_err(|e| DockerError::Container(format!("Failed to remove container {name}: {e}")))?;
236
309
 
237
310
  debug!("Container {} removed", name);
238
311
  Ok(())
@@ -248,8 +321,7 @@ pub async fn container_exists(client: &DockerClient, name: &str) -> Result<bool,
248
321
  status_code: 404, ..
249
322
  }) => Ok(false),
250
323
  Err(e) => Err(DockerError::Container(format!(
251
- "Failed to inspect container {}: {}",
252
- name, e
324
+ "Failed to inspect container {name}: {e}"
253
325
  ))),
254
326
  }
255
327
  }
@@ -267,8 +339,7 @@ pub async fn container_is_running(client: &DockerClient, name: &str) -> Result<b
267
339
  status_code: 404, ..
268
340
  }) => Ok(false),
269
341
  Err(e) => Err(DockerError::Container(format!(
270
- "Failed to inspect container {}: {}",
271
- name, e
342
+ "Failed to inspect container {name}: {e}"
272
343
  ))),
273
344
  }
274
345
  }
@@ -289,16 +360,113 @@ pub async fn container_state(client: &DockerClient, name: &str) -> Result<String
289
360
  Err(bollard::errors::Error::DockerResponseServerError {
290
361
  status_code: 404, ..
291
362
  }) => Err(DockerError::Container(format!(
292
- "Container '{}' not found",
293
- name
363
+ "Container '{name}' not found"
294
364
  ))),
295
365
  Err(e) => Err(DockerError::Container(format!(
296
- "Failed to inspect container {}: {}",
297
- name, e
366
+ "Failed to inspect container {name}: {e}"
298
367
  ))),
299
368
  }
300
369
  }
301
370
 
371
+ /// Container port configuration
372
+ #[derive(Debug, Clone)]
373
+ pub struct ContainerPorts {
374
+ /// Host port for opencode web UI (mapped from container port 3000)
375
+ pub opencode_port: Option<u16>,
376
+ /// Host port for Cockpit (mapped from container port 9090)
377
+ pub cockpit_port: Option<u16>,
378
+ }
379
+
380
+ /// A bind mount from an existing container
381
+ #[derive(Debug, Clone)]
382
+ pub struct ContainerBindMount {
383
+ /// Source path on host
384
+ pub source: String,
385
+ /// Target path in container
386
+ pub target: String,
387
+ /// Read-only flag
388
+ pub read_only: bool,
389
+ }
390
+
391
+ /// Get the port bindings from an existing container
392
+ ///
393
+ /// Returns the host ports that the container's internal ports are mapped to.
394
+ /// Returns None for ports that aren't mapped.
395
+ pub async fn get_container_ports(
396
+ client: &DockerClient,
397
+ name: &str,
398
+ ) -> Result<ContainerPorts, DockerError> {
399
+ debug!("Getting container ports: {}", name);
400
+
401
+ let info = client
402
+ .inner()
403
+ .inspect_container(name, None)
404
+ .await
405
+ .map_err(|e| DockerError::Container(format!("Failed to inspect container {name}: {e}")))?;
406
+
407
+ let port_bindings = info
408
+ .host_config
409
+ .and_then(|hc| hc.port_bindings)
410
+ .unwrap_or_default();
411
+
412
+ // Extract opencode port (3000/tcp -> host port)
413
+ let opencode_port = port_bindings
414
+ .get("3000/tcp")
415
+ .and_then(|bindings| bindings.as_ref())
416
+ .and_then(|bindings| bindings.first())
417
+ .and_then(|binding| binding.host_port.as_ref())
418
+ .and_then(|port_str| port_str.parse::<u16>().ok());
419
+
420
+ // Extract cockpit port (9090/tcp -> host port)
421
+ let cockpit_port = port_bindings
422
+ .get("9090/tcp")
423
+ .and_then(|bindings| bindings.as_ref())
424
+ .and_then(|bindings| bindings.first())
425
+ .and_then(|binding| binding.host_port.as_ref())
426
+ .and_then(|port_str| port_str.parse::<u16>().ok());
427
+
428
+ Ok(ContainerPorts {
429
+ opencode_port,
430
+ cockpit_port,
431
+ })
432
+ }
433
+
434
+ /// Get bind mounts from an existing container
435
+ ///
436
+ /// Returns only user-defined bind mounts (excludes system mounts like cgroup).
437
+ pub async fn get_container_bind_mounts(
438
+ client: &DockerClient,
439
+ name: &str,
440
+ ) -> Result<Vec<ContainerBindMount>, DockerError> {
441
+ debug!("Getting container bind mounts: {}", name);
442
+
443
+ let info = client
444
+ .inner()
445
+ .inspect_container(name, None)
446
+ .await
447
+ .map_err(|e| DockerError::Container(format!("Failed to inspect container {name}: {e}")))?;
448
+
449
+ let mounts = info.mounts.unwrap_or_default();
450
+
451
+ // Filter to only bind mounts, excluding system paths
452
+ let bind_mounts: Vec<ContainerBindMount> = mounts
453
+ .iter()
454
+ .filter(|m| m.typ == Some(MountPointTypeEnum::BIND))
455
+ .filter(|m| {
456
+ // Exclude system mounts (cgroup, etc.)
457
+ let target = m.destination.as_deref().unwrap_or("");
458
+ !target.starts_with("/sys/")
459
+ })
460
+ .map(|m| ContainerBindMount {
461
+ source: m.source.clone().unwrap_or_default(),
462
+ target: m.destination.clone().unwrap_or_default(),
463
+ read_only: m.rw.map(|rw| !rw).unwrap_or(false),
464
+ })
465
+ .collect();
466
+
467
+ Ok(bind_mounts)
468
+ }
469
+
302
470
  #[cfg(test)]
303
471
  mod tests {
304
472
  use super::*;
@@ -312,6 +480,6 @@ mod tests {
312
480
  #[test]
313
481
  fn default_image_format() {
314
482
  let expected = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
315
- assert_eq!(expected, "ghcr.io/prizz/opencode-cloud:latest");
483
+ assert_eq!(expected, "ghcr.io/prizz/opencode-cloud-sandbox:latest");
316
484
  }
317
485
  }
@@ -1,9 +1,13 @@
1
1
  //! Embedded Dockerfile content
2
2
  //!
3
- //! This module contains the Dockerfile for building the opencode-cloud container image,
4
- //! embedded at compile time for distribution with the CLI.
3
+ //! This module contains the Dockerfile for building the opencode-cloud-sandbox
4
+ //! container image, embedded at compile time for distribution with the CLI.
5
+ //!
6
+ //! Note: The image is named "opencode-cloud-sandbox" (not "opencode-cloud") to
7
+ //! clearly indicate this is the sandboxed container environment that the
8
+ //! opencode-cloud CLI deploys, not the CLI tool itself.
5
9
 
6
- /// The Dockerfile for building the opencode-cloud container image
10
+ /// The Dockerfile for building the opencode-cloud-sandbox container image
7
11
  pub const DOCKERFILE: &str = include_str!("Dockerfile");
8
12
 
9
13
  // =============================================================================
@@ -15,27 +19,26 @@ pub const DOCKERFILE: &str = include_str!("Dockerfile");
15
19
  // - Registry: The server hosting the image (e.g., ghcr.io, gcr.io, docker.io)
16
20
  // When omitted, Docker Hub (docker.io) is assumed.
17
21
  // - Namespace: Usually the username or organization (e.g., prizz)
18
- // - Image: The image name (e.g., opencode-cloud)
22
+ // - Image: The image name (e.g., opencode-cloud-sandbox)
19
23
  // - Tag: Version identifier (e.g., latest, v1.0.0). Defaults to "latest" if omitted.
20
24
  //
21
25
  // Examples:
22
- // ghcr.io/prizz/opencode-cloud:latest - GitHub Container Registry
23
- // prizz/opencode-cloud:latest - Docker Hub (registry omitted)
24
- // gcr.io/my-project/myapp:v1.0 - Google Container Registry
26
+ // ghcr.io/prizz/opencode-cloud-sandbox:latest - GitHub Container Registry
27
+ // prizz/opencode-cloud-sandbox:latest - Docker Hub (registry omitted)
28
+ // gcr.io/my-project/myapp:v1.0 - Google Container Registry
25
29
  //
26
- // We use GHCR as the primary registry since it integrates well with GitHub
27
- // Actions for CI/CD publishing.
30
+ // We publish to both GHCR (primary) and Docker Hub for maximum accessibility.
28
31
  // =============================================================================
29
32
 
30
33
  /// Docker image name for GitHub Container Registry (primary registry)
31
34
  ///
32
35
  /// Format: `ghcr.io/{github-username}/{image-name}`
33
- pub const IMAGE_NAME_GHCR: &str = "ghcr.io/prizz/opencode-cloud";
36
+ pub const IMAGE_NAME_GHCR: &str = "ghcr.io/prizz/opencode-cloud-sandbox";
34
37
 
35
- /// Docker image name for Docker Hub (fallback registry)
38
+ /// Docker image name for Docker Hub (secondary registry)
36
39
  ///
37
40
  /// Format: `{dockerhub-username}/{image-name}` (registry prefix omitted for Docker Hub)
38
- pub const IMAGE_NAME_DOCKERHUB: &str = "prizz/opencode-cloud";
41
+ pub const IMAGE_NAME_DOCKERHUB: &str = "prizz/opencode-cloud-sandbox";
39
42
 
40
43
  /// Default image tag
41
44
  pub const IMAGE_TAG_DEFAULT: &str = "latest";