@opencode-cloud/core 13.0.1 → 14.0.0

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 = "13.0.1"
3
+ version = "14.0.0"
4
4
  edition = "2024"
5
5
  rust-version = "1.89"
6
6
  license = "MIT"
package/README.md CHANGED
@@ -191,6 +191,16 @@ occ logs
191
191
  # Follow logs in real-time
192
192
  occ logs -f
193
193
 
194
+ # View opencode-broker logs (systemd/journald required)
195
+ occ logs --broker
196
+
197
+ # Dump opencode-broker logs (no follow)
198
+ occ logs --broker --no-follow
199
+
200
+ # Note: Broker logs require systemd/journald. This is enabled by default on supported Linux
201
+ # hosts. Docker Desktop/macOS/Windows use Tini, so broker logs aren't available there.
202
+ # Existing containers may need to be recreated after upgrading.
203
+
194
204
  # Stop the service
195
205
  occ stop
196
206
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode-cloud/core",
3
- "version": "13.0.1",
3
+ "version": "14.0.0",
4
4
  "description": "Core NAPI bindings for opencode-cloud (internal package)",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -100,13 +100,10 @@ pub struct Config {
100
100
  /// Enable Cockpit web console (default: false)
101
101
  ///
102
102
  /// When enabled:
103
- /// - Container uses systemd as init (required for Cockpit)
104
103
  /// - Requires Linux host with native Docker (does NOT work on macOS Docker Desktop)
105
104
  /// - Cockpit web UI accessible at cockpit_port
106
105
  ///
107
106
  /// When disabled (default):
108
- /// - Container uses tini as init (lightweight, works everywhere)
109
- /// - Works on macOS, Linux, and Windows
110
107
  /// - No Cockpit web UI
111
108
  #[serde(default = "default_cockpit_enabled")]
112
109
  pub cockpit_enabled: bool,
@@ -35,7 +35,7 @@
35
35
  FROM ubuntu:25.10 AS base
36
36
 
37
37
  # OCI Labels for image metadata
38
- LABEL org.opencontainers.image.title="opencode-cloud"
38
+ LABEL org.opencontainers.image.title="opencode-cloud-sandbox"
39
39
  # NOTE: This exact label format is parsed by scripts/extract-oci-description.py
40
40
  # (called from .github/workflows/docker-publish.yml) to populate multi-arch
41
41
  # manifest annotations for GHCR. If you change this line (format, quoting, or
@@ -535,7 +535,7 @@ USER opencode
535
535
  # commit on the main branch of https://github.com/pRizz/opencode.
536
536
  # Update it by running: ./scripts/update-opencode-commit.sh
537
537
  # Build opencode from source (BuildKit cache mounts disabled for now)
538
- RUN OPENCODE_COMMIT="ce0a5f0f2a8abfb9a00e335a41e7cfd3487dbb18" \
538
+ RUN OPENCODE_COMMIT="3010d7e55e1f26a18684234ea12428d3f91db392" \
539
539
  && rm -rf /tmp/opencode-repo \
540
540
  && git clone --depth 1 https://github.com/pRizz/opencode.git /tmp/opencode-repo \
541
541
  && cd /tmp/opencode-repo \
@@ -4,11 +4,15 @@
4
4
  //! errors gracefully and provides clear error messages.
5
5
 
6
6
  use bollard::Docker;
7
+ use std::path::PathBuf;
7
8
  use std::time::Duration;
8
9
 
9
10
  use super::error::DockerError;
10
11
  use crate::host::{HostConfig, SshTunnel};
11
12
 
13
+ /// Default Unix socket path used when `DOCKER_HOST` does not specify a socket.
14
+ const DEFAULT_UNIX_SOCKET: &str = "/var/run/docker.sock";
15
+
12
16
  /// Docker client wrapper with connection handling
13
17
  pub struct DockerClient {
14
18
  inner: Docker,
@@ -16,6 +20,22 @@ pub struct DockerClient {
16
20
  _tunnel: Option<SshTunnel>,
17
21
  /// Host name for remote connections (None = local)
18
22
  host_name: Option<String>,
23
+ /// Connection info for raw HTTP calls that bypass Bollard models.
24
+ endpoint: DockerEndpoint,
25
+ }
26
+
27
+ /// Docker API endpoint details for raw HTTP calls.
28
+ ///
29
+ /// We keep this alongside the Bollard client because some Docker API responses
30
+ /// (notably `/system/df` in API v1.52+) do not deserialize cleanly into the
31
+ /// Bollard-generated models. The CLI uses this endpoint to fetch and parse
32
+ /// those responses directly.
33
+ #[derive(Clone, Debug)]
34
+ pub enum DockerEndpoint {
35
+ /// Unix domain socket path (local Docker).
36
+ Unix(PathBuf),
37
+ /// HTTP base URL (remote Docker via SSH tunnel).
38
+ Http(String),
19
39
  }
20
40
 
21
41
  impl DockerClient {
@@ -24,6 +44,7 @@ impl DockerClient {
24
44
  /// Uses platform-appropriate socket (Unix socket on Linux/macOS).
25
45
  /// Returns a clear error if Docker is not running or accessible.
26
46
  pub fn new() -> Result<Self, DockerError> {
47
+ let endpoint = Self::resolve_local_endpoint();
27
48
  let docker = Docker::connect_with_local_defaults()
28
49
  .map_err(|e| DockerError::Connection(e.to_string()))?;
29
50
 
@@ -31,6 +52,7 @@ impl DockerClient {
31
52
  inner: docker,
32
53
  _tunnel: None,
33
54
  host_name: None,
55
+ endpoint,
34
56
  })
35
57
  }
36
58
 
@@ -39,6 +61,7 @@ impl DockerClient {
39
61
  /// Use for long-running operations like image builds.
40
62
  /// Default timeout is 120 seconds; build timeout should be 600+ seconds.
41
63
  pub fn with_timeout(timeout_secs: u64) -> Result<Self, DockerError> {
64
+ let endpoint = Self::resolve_local_endpoint();
42
65
  let docker = Docker::connect_with_local_defaults()
43
66
  .map_err(|e| DockerError::Connection(e.to_string()))?
44
67
  .with_timeout(Duration::from_secs(timeout_secs));
@@ -47,6 +70,7 @@ impl DockerClient {
47
70
  inner: docker,
48
71
  _tunnel: None,
49
72
  host_name: None,
73
+ endpoint,
50
74
  })
51
75
  }
52
76
 
@@ -62,6 +86,7 @@ impl DockerClient {
62
86
  // Create SSH tunnel
63
87
  let tunnel = SshTunnel::new(host, host_name)
64
88
  .map_err(|e| DockerError::Connection(format!("SSH tunnel failed: {e}")))?;
89
+ let endpoint = Self::endpoint_from_tunnel(&tunnel);
65
90
 
66
91
  // Wait for tunnel to be ready with exponential backoff
67
92
  tunnel
@@ -94,6 +119,7 @@ impl DockerClient {
94
119
  inner: docker,
95
120
  _tunnel: Some(tunnel),
96
121
  host_name: Some(host_name.to_string()),
122
+ endpoint,
97
123
  });
98
124
  }
99
125
  Err(e) => {
@@ -124,6 +150,7 @@ impl DockerClient {
124
150
  ) -> Result<Self, DockerError> {
125
151
  let tunnel = SshTunnel::new(host, host_name)
126
152
  .map_err(|e| DockerError::Connection(format!("SSH tunnel failed: {e}")))?;
153
+ let endpoint = Self::endpoint_from_tunnel(&tunnel);
127
154
 
128
155
  tunnel
129
156
  .wait_ready()
@@ -143,6 +170,7 @@ impl DockerClient {
143
170
  inner: docker,
144
171
  _tunnel: Some(tunnel),
145
172
  host_name: Some(host_name.to_string()),
173
+ endpoint,
146
174
  })
147
175
  }
148
176
 
@@ -177,10 +205,40 @@ impl DockerClient {
177
205
  self._tunnel.is_some()
178
206
  }
179
207
 
208
+ /// Return the endpoint details used for raw Docker API calls.
209
+ ///
210
+ /// This exists to support endpoints whose response schemas are newer than
211
+ /// the Bollard-generated models (e.g., `/system/df` in newer Docker APIs).
212
+ pub fn endpoint(&self) -> &DockerEndpoint {
213
+ &self.endpoint
214
+ }
215
+
180
216
  /// Access inner Bollard client for advanced operations
181
217
  pub fn inner(&self) -> &Docker {
182
218
  &self.inner
183
219
  }
220
+
221
+ /// Resolve the local Unix socket path used by the Docker client.
222
+ ///
223
+ /// Bollard's `connect_with_local_defaults` only honors `DOCKER_HOST` when it
224
+ /// starts with `unix://`. We mirror that logic so our raw HTTP calls use the
225
+ /// same socket, which is necessary because Bollard's `/system/df` models
226
+ /// don't match newer Docker API responses.
227
+ fn resolve_local_endpoint() -> DockerEndpoint {
228
+ let socket = std::env::var("DOCKER_HOST")
229
+ .ok()
230
+ .and_then(|host| host.strip_prefix("unix://").map(|path| path.to_string()))
231
+ .unwrap_or_else(|| DEFAULT_UNIX_SOCKET.to_string());
232
+ DockerEndpoint::Unix(PathBuf::from(socket))
233
+ }
234
+
235
+ /// Build an HTTP base URL for a Docker daemon reachable via SSH tunnel.
236
+ ///
237
+ /// We store this so the CLI can query `/system/df` directly when Bollard's
238
+ /// data-usage models are out of date.
239
+ fn endpoint_from_tunnel(tunnel: &SshTunnel) -> DockerEndpoint {
240
+ DockerEndpoint::Http(format!("http://127.0.0.1:{}", tunnel.local_port()))
241
+ }
184
242
  }
185
243
 
186
244
  #[cfg(test)]
@@ -45,6 +45,7 @@ fn has_env_key(env: &[String], key: &str) -> bool {
45
45
  /// * `bind_address` - IP address to bind on host (defaults to "127.0.0.1")
46
46
  /// * `cockpit_port` - Port to bind on host for Cockpit (defaults to 9090)
47
47
  /// * `cockpit_enabled` - Whether to enable Cockpit port mapping (defaults to false)
48
+ /// * `systemd_enabled` - Whether to use systemd as init (defaults to false)
48
49
  /// * `bind_mounts` - User-defined bind mounts from config and CLI flags (optional)
49
50
  #[allow(clippy::too_many_arguments)]
50
51
  pub async fn create_container(
@@ -56,6 +57,7 @@ pub async fn create_container(
56
57
  bind_address: Option<&str>,
57
58
  cockpit_port: Option<u16>,
58
59
  cockpit_enabled: Option<bool>,
60
+ systemd_enabled: Option<bool>,
59
61
  bind_mounts: Option<Vec<ParsedMount>>,
60
62
  ) -> Result<String, DockerError> {
61
63
  let container_name = name.unwrap_or(CONTAINER_NAME);
@@ -64,10 +66,16 @@ pub async fn create_container(
64
66
  let port = opencode_web_port.unwrap_or(OPENCODE_WEB_PORT);
65
67
  let cockpit_port_val = cockpit_port.unwrap_or(9090);
66
68
  let cockpit_enabled_val = cockpit_enabled.unwrap_or(false);
69
+ let systemd_enabled_val = systemd_enabled.unwrap_or(false);
67
70
 
68
71
  debug!(
69
- "Creating container {} from image {} with port {} and cockpit_port {} (enabled: {})",
70
- container_name, image_name, port, cockpit_port_val, cockpit_enabled_val
72
+ "Creating container {} from image {} with port {} and cockpit_port {} (enabled: {}, systemd: {})",
73
+ container_name,
74
+ image_name,
75
+ port,
76
+ cockpit_port_val,
77
+ cockpit_enabled_val,
78
+ systemd_enabled_val
71
79
  );
72
80
 
73
81
  // Check if container already exists
@@ -162,9 +170,9 @@ pub async fn create_container(
162
170
  }
163
171
 
164
172
  // Create host config
165
- // When Cockpit is enabled, add systemd-specific settings (requires Linux host)
166
- // When Cockpit is disabled, use simpler tini-based config (works everywhere)
167
- let host_config = if cockpit_enabled_val {
173
+ // When systemd is enabled, add systemd-specific settings (requires Linux host)
174
+ // When systemd is disabled, use simpler tini-based config (works everywhere)
175
+ let host_config = if systemd_enabled_val {
168
176
  HostConfig {
169
177
  mounts: Some(mounts),
170
178
  port_bindings: Some(port_bindings),
@@ -217,8 +225,8 @@ pub async fn create_container(
217
225
  if !has_env_key(&env, "XDG_CACHE_HOME") {
218
226
  env.push("XDG_CACHE_HOME=/home/opencode/.cache".to_string());
219
227
  }
220
- // Add USE_SYSTEMD=1 when Cockpit is enabled to tell entrypoint to use systemd
221
- if cockpit_enabled_val && !has_env_key(&env, "USE_SYSTEMD") {
228
+ // Add USE_SYSTEMD=1 when systemd is enabled to tell entrypoint to use systemd
229
+ if systemd_enabled_val && !has_env_key(&env, "USE_SYSTEMD") {
222
230
  env.push("USE_SYSTEMD=1".to_string());
223
231
  }
224
232
  let final_env = if env.is_empty() { None } else { Some(env) };
@@ -70,9 +70,9 @@ pub async fn image_exists(
70
70
  }
71
71
  }
72
72
 
73
- /// Remove all images whose tags or digests contain the provided name fragment
73
+ /// Remove all images whose tags, digests, or labels match the provided name fragment
74
74
  ///
75
- /// Returns the number of image references removed.
75
+ /// Returns the number of images removed.
76
76
  pub async fn remove_images_by_name(
77
77
  client: &DockerClient,
78
78
  name_fragment: &str,
@@ -82,8 +82,8 @@ pub async fn remove_images_by_name(
82
82
 
83
83
  let images = list_docker_images(client).await?;
84
84
 
85
- let references = collect_image_references(&images, name_fragment);
86
- remove_image_references(client, references, force).await
85
+ let image_ids = collect_image_ids(&images, name_fragment);
86
+ remove_image_ids(client, image_ids, force).await
87
87
  }
88
88
 
89
89
  /// List all local Docker images (including intermediate layers).
@@ -98,47 +98,87 @@ async fn list_docker_images(
98
98
  .map_err(|e| DockerError::Image(format!("Failed to list images: {e}")))
99
99
  }
100
100
 
101
- /// Collect tags and digests that contain the provided name fragment.
102
- fn collect_image_references(
101
+ const LABEL_TITLE: &str = "org.opencontainers.image.title";
102
+ const LABEL_SOURCE: &str = "org.opencontainers.image.source";
103
+ const LABEL_URL: &str = "org.opencontainers.image.url";
104
+
105
+ const LABEL_TITLE_VALUE: &str = "opencode-cloud-sandbox";
106
+ const LABEL_SOURCE_VALUE: &str = "https://github.com/pRizz/opencode-cloud";
107
+ const LABEL_URL_VALUE: &str = "https://github.com/pRizz/opencode-cloud";
108
+
109
+ /// Collect image IDs that contain the provided name fragment or match opencode labels.
110
+ fn collect_image_ids(
103
111
  images: &[bollard::models::ImageSummary],
104
112
  name_fragment: &str,
105
113
  ) -> HashSet<String> {
106
- let mut references = HashSet::new();
114
+ let mut image_ids = HashSet::new();
107
115
  for image in images {
108
- for tag in &image.repo_tags {
109
- if tag != "<none>:<none>" && tag.contains(name_fragment) {
110
- references.insert(tag.to_string());
111
- }
112
- }
113
-
114
- for digest in &image.repo_digests {
115
- if digest.contains(name_fragment) {
116
- references.insert(digest.to_string());
117
- }
116
+ if image_matches_fragment_or_labels(image, name_fragment) {
117
+ image_ids.insert(image.id.clone());
118
118
  }
119
119
  }
120
- references
120
+ image_ids
121
+ }
122
+
123
+ fn image_matches_fragment_or_labels(
124
+ image: &bollard::models::ImageSummary,
125
+ name_fragment: &str,
126
+ ) -> bool {
127
+ let tag_match = image
128
+ .repo_tags
129
+ .iter()
130
+ .any(|tag| tag != "<none>:<none>" && tag.contains(name_fragment));
131
+ let digest_match = image
132
+ .repo_digests
133
+ .iter()
134
+ .any(|digest| digest.contains(name_fragment));
135
+ let label_match = image_labels_match(&image.labels);
136
+
137
+ tag_match || digest_match || label_match
121
138
  }
122
139
 
123
- /// Remove image references (tags/digests), returning the number removed.
124
- async fn remove_image_references(
140
+ fn image_labels_match(labels: &HashMap<String, String>) -> bool {
141
+ labels
142
+ .get(LABEL_SOURCE)
143
+ .is_some_and(|value| value == LABEL_SOURCE_VALUE)
144
+ || labels
145
+ .get(LABEL_URL)
146
+ .is_some_and(|value| value == LABEL_URL_VALUE)
147
+ || labels
148
+ .get(LABEL_TITLE)
149
+ .is_some_and(|value| value == LABEL_TITLE_VALUE)
150
+ }
151
+
152
+ /// Remove image IDs, returning the number removed.
153
+ async fn remove_image_ids(
125
154
  client: &DockerClient,
126
- references: HashSet<String>,
155
+ image_ids: HashSet<String>,
127
156
  force: bool,
128
157
  ) -> Result<usize, DockerError> {
129
- if references.is_empty() {
158
+ if image_ids.is_empty() {
130
159
  return Ok(0);
131
160
  }
132
161
 
133
162
  let remove_options = RemoveImageOptionsBuilder::new().force(force).build();
134
163
  let mut removed = 0usize;
135
- for reference in references {
136
- client
164
+ for image_id in image_ids {
165
+ let result = client
137
166
  .inner()
138
- .remove_image(&reference, Some(remove_options.clone()), None)
139
- .await
140
- .map_err(|e| DockerError::Image(format!("Failed to remove image {reference}: {e}")))?;
141
- removed += 1;
167
+ .remove_image(&image_id, Some(remove_options.clone()), None)
168
+ .await;
169
+ match result {
170
+ Ok(_) => removed += 1,
171
+ Err(bollard::errors::Error::DockerResponseServerError {
172
+ status_code: 404, ..
173
+ }) => {
174
+ debug!("Docker image already removed: {}", image_id);
175
+ }
176
+ Err(err) => {
177
+ return Err(DockerError::Image(format!(
178
+ "Failed to remove image {image_id}: {err}"
179
+ )));
180
+ }
181
+ }
142
182
  }
143
183
 
144
184
  Ok(removed)
@@ -867,6 +907,32 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
867
907
  #[cfg(test)]
868
908
  mod tests {
869
909
  use super::*;
910
+ use bollard::models::ImageSummary;
911
+ use std::collections::HashMap;
912
+
913
+ fn make_image_summary(
914
+ id: &str,
915
+ tags: Vec<&str>,
916
+ digests: Vec<&str>,
917
+ labels: HashMap<String, String>,
918
+ ) -> ImageSummary {
919
+ ImageSummary {
920
+ id: id.to_string(),
921
+ parent_id: String::new(),
922
+ repo_tags: tags.into_iter().map(|tag| tag.to_string()).collect(),
923
+ repo_digests: digests
924
+ .into_iter()
925
+ .map(|digest| digest.to_string())
926
+ .collect(),
927
+ created: 0,
928
+ size: 0,
929
+ shared_size: -1,
930
+ labels,
931
+ containers: 0,
932
+ manifests: None,
933
+ descriptor: None,
934
+ }
935
+ }
870
936
 
871
937
  #[test]
872
938
  fn create_build_context_succeeds() {
@@ -980,4 +1046,24 @@ mod tests {
980
1046
  assert!(!is_error_line("Compiling foo v1.0"));
981
1047
  assert!(!is_error_line("Successfully installed"));
982
1048
  }
1049
+
1050
+ #[test]
1051
+ fn collect_image_ids_matches_labels() {
1052
+ let mut labels = HashMap::new();
1053
+ labels.insert(LABEL_SOURCE.to_string(), LABEL_SOURCE_VALUE.to_string());
1054
+
1055
+ let images = vec![
1056
+ make_image_summary("sha256:opencode", vec![], vec![], labels),
1057
+ make_image_summary(
1058
+ "sha256:other",
1059
+ vec!["busybox:latest"],
1060
+ vec![],
1061
+ HashMap::new(),
1062
+ ),
1063
+ ];
1064
+
1065
+ let ids = collect_image_ids(&images, "opencode-cloud-sandbox");
1066
+ assert!(ids.contains("sha256:opencode"));
1067
+ assert!(!ids.contains("sha256:other"));
1068
+ }
983
1069
  }
package/src/docker/mod.rs CHANGED
@@ -29,7 +29,7 @@ mod version;
29
29
  pub mod volume;
30
30
 
31
31
  // Core types
32
- pub use client::DockerClient;
32
+ pub use client::{DockerClient, DockerEndpoint};
33
33
  pub use error::DockerError;
34
34
  pub use progress::ProgressReporter;
35
35
 
@@ -71,6 +71,39 @@ pub use volume::{
71
71
  VOLUME_USERS, ensure_volumes_exist, remove_all_volumes, remove_volume, volume_exists,
72
72
  };
73
73
 
74
+ /// Determine whether the Docker host supports systemd-in-container.
75
+ ///
76
+ /// Returns true only for Linux hosts that are not Docker Desktop and not rootless.
77
+ pub async fn docker_supports_systemd(client: &DockerClient) -> Result<bool, DockerError> {
78
+ let info = client.inner().info().await.map_err(DockerError::from)?;
79
+
80
+ let os_type = info.os_type.unwrap_or_default();
81
+ if os_type.to_lowercase() != "linux" {
82
+ return Ok(false);
83
+ }
84
+
85
+ let operating_system = match info.operating_system {
86
+ Some(value) => value,
87
+ None => return Ok(false),
88
+ };
89
+ if operating_system.to_lowercase().contains("docker desktop") {
90
+ return Ok(false);
91
+ }
92
+
93
+ let security_options = match info.security_options {
94
+ Some(options) => options,
95
+ None => return Ok(false),
96
+ };
97
+ let is_rootless = security_options
98
+ .iter()
99
+ .any(|opt| opt.to_lowercase().contains("name=rootless"));
100
+ if is_rootless {
101
+ return Ok(false);
102
+ }
103
+
104
+ Ok(true)
105
+ }
106
+
74
107
  // Bind mount parsing and validation
75
108
  pub use mount::{MountError, ParsedMount, check_container_path_warning, validate_mount_path};
76
109
 
@@ -95,8 +128,10 @@ pub use state::{ImageState, clear_state, get_state_path, load_state, save_state}
95
128
  /// * `env_vars` - Additional environment variables (optional)
96
129
  /// * `bind_address` - IP address to bind on host (defaults to "127.0.0.1")
97
130
  /// * `cockpit_port` - Port to bind on host for Cockpit (defaults to 9090)
98
- /// * `cockpit_enabled` - Whether to enable Cockpit port mapping (defaults to true)
131
+ /// * `cockpit_enabled` - Whether to enable Cockpit port mapping (defaults to false)
132
+ /// * `systemd_enabled` - Whether to use systemd as init (defaults to false)
99
133
  /// * `bind_mounts` - User-defined bind mounts from config and CLI flags (optional)
134
+ #[allow(clippy::too_many_arguments)]
100
135
  pub async fn setup_and_start(
101
136
  client: &DockerClient,
102
137
  opencode_web_port: Option<u16>,
@@ -104,6 +139,7 @@ pub async fn setup_and_start(
104
139
  bind_address: Option<&str>,
105
140
  cockpit_port: Option<u16>,
106
141
  cockpit_enabled: Option<bool>,
142
+ systemd_enabled: Option<bool>,
107
143
  bind_mounts: Option<Vec<mount::ParsedMount>>,
108
144
  ) -> Result<String, DockerError> {
109
145
  // Ensure volumes exist first
@@ -132,6 +168,7 @@ pub async fn setup_and_start(
132
168
  bind_address,
133
169
  cockpit_port,
134
170
  cockpit_enabled,
171
+ systemd_enabled,
135
172
  bind_mounts,
136
173
  )
137
174
  .await?