@opencode-cloud/core 4.3.0 → 5.0.1

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.3.0"
3
+ version = "5.0.1"
4
4
  edition = "2024"
5
5
  rust-version = "1.88"
6
6
  license = "MIT"
package/README.md CHANGED
@@ -191,6 +191,12 @@ occ user add <username> --generate
191
191
  - Remove user: `occ user remove <username>`
192
192
  - Enable/disable account: `occ user enable <username>` / `occ user disable <username>`
193
193
 
194
+ ### User Persistence
195
+
196
+ User accounts (including password hashes and lock status) persist across container updates and rebuilds.
197
+ The CLI stores user records in a managed Docker volume mounted at `/var/lib/opencode-users` inside the container.
198
+ No plaintext passwords are stored on the host.
199
+
194
200
  ### Legacy Authentication Fields
195
201
 
196
202
  The `auth_username` and `auth_password` config fields are **deprecated** and ignored. They are kept in the config schema for backward compatibility with existing deployments, but new users should be created via `occ user add` instead.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode-cloud/core",
3
- "version": "4.3.0",
3
+ "version": "5.0.1",
4
4
  "description": "Core NAPI bindings for opencode-cloud (internal package)",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -6,8 +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_CACHE, MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, MOUNT_STATE, VOLUME_CACHE,
10
- VOLUME_CONFIG, VOLUME_PROJECTS, VOLUME_SESSION, VOLUME_STATE,
9
+ MOUNT_CACHE, MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, MOUNT_STATE, MOUNT_USERS,
10
+ VOLUME_CACHE, VOLUME_CONFIG, VOLUME_PROJECTS, VOLUME_SESSION, VOLUME_STATE, VOLUME_USERS,
11
11
  };
12
12
  use super::{DockerClient, DockerError};
13
13
  use bollard::container::{
@@ -121,6 +121,7 @@ pub async fn create_container(
121
121
  add_volume_mount(MOUNT_CACHE, VOLUME_CACHE);
122
122
  add_volume_mount(MOUNT_PROJECTS, VOLUME_PROJECTS);
123
123
  add_volume_mount(MOUNT_CONFIG, VOLUME_CONFIG);
124
+ add_volume_mount(MOUNT_USERS, VOLUME_USERS);
124
125
 
125
126
  // Add user-defined bind mounts from config/CLI
126
127
  if let Some(ref user_mounts) = bind_mounts {
package/src/docker/mod.rs CHANGED
@@ -54,15 +54,15 @@ pub use exec::{exec_command, exec_command_exit_code, exec_command_with_stdin};
54
54
 
55
55
  // User management operations
56
56
  pub use users::{
57
- UserInfo, create_user, delete_user, list_users, lock_user, set_user_password, unlock_user,
58
- user_exists,
57
+ UserInfo, create_user, delete_user, list_users, lock_user, persist_user, remove_persisted_user,
58
+ restore_persisted_users, set_user_password, unlock_user, user_exists,
59
59
  };
60
60
 
61
61
  // Volume management
62
62
  pub use volume::{
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,
63
+ MOUNT_CACHE, MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, MOUNT_STATE, MOUNT_USERS,
64
+ VOLUME_CACHE, VOLUME_CONFIG, VOLUME_NAMES, VOLUME_PROJECTS, VOLUME_SESSION, VOLUME_STATE,
65
+ VOLUME_USERS, ensure_volumes_exist, remove_all_volumes, remove_volume, volume_exists,
66
66
  };
67
67
 
68
68
  // Bind mount parsing and validation
@@ -136,6 +136,9 @@ pub async fn setup_and_start(
136
136
  container::start_container(client, container::CONTAINER_NAME).await?;
137
137
  }
138
138
 
139
+ // Restore persisted users after the container is running
140
+ users::restore_persisted_users(client, container::CONTAINER_NAME).await?;
141
+
139
142
  Ok(container_id)
140
143
  }
141
144
 
@@ -8,7 +8,15 @@
8
8
  //! Instead, we use `chpasswd` which reads from stdin.
9
9
 
10
10
  use super::exec::{exec_command, exec_command_exit_code, exec_command_with_stdin};
11
+ use super::volume::MOUNT_USERS;
11
12
  use super::{DockerClient, DockerError};
13
+ use serde::{Deserialize, Serialize};
14
+
15
+ /// User persistence store directory inside the container.
16
+ ///
17
+ /// Format: one JSON file per user with strict permissions (root-owned, 0700 dir, 0600 files).
18
+ /// Stored on a managed Docker volume mounted at this path.
19
+ const USERS_STORE_DIR: &str = MOUNT_USERS;
12
20
 
13
21
  /// Information about a container user
14
22
  #[derive(Debug, Clone, PartialEq)]
@@ -25,6 +33,13 @@ pub struct UserInfo {
25
33
  pub locked: bool,
26
34
  }
27
35
 
36
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37
+ struct PersistedUserRecord {
38
+ username: String,
39
+ password_hash: String,
40
+ locked: bool,
41
+ }
42
+
28
43
  /// Create a new user in the container
29
44
  ///
30
45
  /// Creates a user with a home directory and /bin/bash shell.
@@ -96,6 +111,31 @@ pub async fn set_user_password(
96
111
  Ok(())
97
112
  }
98
113
 
114
+ /// Set a user's password hash directly (no plaintext required)
115
+ ///
116
+ /// Uses `usermod -p` with a precomputed shadow hash.
117
+ async fn set_user_password_hash(
118
+ client: &DockerClient,
119
+ container: &str,
120
+ username: &str,
121
+ password_hash: &str,
122
+ ) -> Result<(), DockerError> {
123
+ if password_hash.is_empty() {
124
+ return Ok(());
125
+ }
126
+
127
+ let cmd = vec!["usermod", "-p", password_hash, username];
128
+ let exit_code = exec_command_exit_code(client, container, cmd).await?;
129
+
130
+ if exit_code != 0 {
131
+ return Err(DockerError::Container(format!(
132
+ "Failed to set password hash for '{username}': usermod returned exit code {exit_code}"
133
+ )));
134
+ }
135
+
136
+ Ok(())
137
+ }
138
+
99
139
  /// Check if a user exists in the container
100
140
  ///
101
141
  /// # Arguments
@@ -234,6 +274,81 @@ pub async fn list_users(
234
274
  Ok(users)
235
275
  }
236
276
 
277
+ /// Persist a user's credentials and lock state to the managed volume.
278
+ ///
279
+ /// Stores the shadow hash (not plaintext) and lock status in a JSON record.
280
+ pub async fn persist_user(
281
+ client: &DockerClient,
282
+ container: &str,
283
+ username: &str,
284
+ ) -> Result<(), DockerError> {
285
+ ensure_users_store_dir(client, container).await?;
286
+
287
+ let shadow_hash = get_user_shadow_hash(client, container, username).await?;
288
+ let locked = is_user_locked(client, container, username).await?;
289
+
290
+ let record = PersistedUserRecord {
291
+ username: username.to_string(),
292
+ password_hash: shadow_hash,
293
+ locked,
294
+ };
295
+
296
+ write_user_record(client, container, &record).await?;
297
+ Ok(())
298
+ }
299
+
300
+ /// Remove a persisted user record from the managed volume.
301
+ pub async fn remove_persisted_user(
302
+ client: &DockerClient,
303
+ container: &str,
304
+ username: &str,
305
+ ) -> Result<(), DockerError> {
306
+ let record_path = user_record_path(username);
307
+ let cmd_string = format!("rm -f {record_path}");
308
+ let cmd = vec!["sh", "-c", cmd_string.as_str()];
309
+ exec_command(client, container, cmd).await?;
310
+ Ok(())
311
+ }
312
+
313
+ /// Restore users from the persisted store into the container.
314
+ ///
315
+ /// Returns the list of usernames restored or updated.
316
+ pub async fn restore_persisted_users(
317
+ client: &DockerClient,
318
+ container: &str,
319
+ ) -> Result<Vec<String>, DockerError> {
320
+ let records = read_user_records(client, container).await?;
321
+ if records.is_empty() {
322
+ let users = list_users(client, container).await?;
323
+ let mut persisted = Vec::new();
324
+ for user in users {
325
+ persist_user(client, container, &user.username).await?;
326
+ persisted.push(user.username);
327
+ }
328
+ return Ok(persisted);
329
+ }
330
+
331
+ let mut restored = Vec::new();
332
+
333
+ for record in records {
334
+ if !user_exists(client, container, &record.username).await? {
335
+ create_user(client, container, &record.username).await?;
336
+ }
337
+
338
+ set_user_password_hash(client, container, &record.username, &record.password_hash).await?;
339
+
340
+ if record.locked {
341
+ lock_user(client, container, &record.username).await?;
342
+ } else {
343
+ unlock_user(client, container, &record.username).await?;
344
+ }
345
+
346
+ restored.push(record.username);
347
+ }
348
+
349
+ Ok(restored)
350
+ }
351
+
237
352
  /// Check if a user account is locked
238
353
  ///
239
354
  /// Uses `passwd -S` to get account status.
@@ -256,6 +371,80 @@ async fn is_user_locked(
256
371
  Ok(false)
257
372
  }
258
373
 
374
+ async fn ensure_users_store_dir(client: &DockerClient, container: &str) -> Result<(), DockerError> {
375
+ let cmd_string = format!("install -d -m 700 {USERS_STORE_DIR}");
376
+ let cmd = vec!["sh", "-c", cmd_string.as_str()];
377
+ exec_command(client, container, cmd).await?;
378
+ Ok(())
379
+ }
380
+
381
+ async fn get_user_shadow_hash(
382
+ client: &DockerClient,
383
+ container: &str,
384
+ username: &str,
385
+ ) -> Result<String, DockerError> {
386
+ let output = exec_command(client, container, vec!["getent", "shadow", username]).await?;
387
+ let line = output.lines().next().unwrap_or("").trim();
388
+ if line.is_empty() {
389
+ return Err(DockerError::Container(format!(
390
+ "Failed to read shadow entry for '{username}'"
391
+ )));
392
+ }
393
+
394
+ let fields: Vec<&str> = line.split(':').collect();
395
+ if fields.len() < 2 {
396
+ return Err(DockerError::Container(format!(
397
+ "Invalid shadow entry for '{username}'"
398
+ )));
399
+ }
400
+
401
+ Ok(fields[1].to_string())
402
+ }
403
+
404
+ async fn read_user_records(
405
+ client: &DockerClient,
406
+ container: &str,
407
+ ) -> Result<Vec<PersistedUserRecord>, DockerError> {
408
+ let list_command =
409
+ format!("if [ -d {USERS_STORE_DIR} ]; then ls -1 {USERS_STORE_DIR}/*.json 2>/dev/null; fi");
410
+ let list_cmd = vec!["sh", "-c", list_command.as_str()];
411
+ let output = exec_command(client, container, list_cmd).await?;
412
+ let mut records = Vec::new();
413
+
414
+ for path in output
415
+ .lines()
416
+ .map(str::trim)
417
+ .filter(|line| !line.is_empty())
418
+ {
419
+ let contents = exec_command(client, container, vec!["cat", path]).await?;
420
+ let record: PersistedUserRecord = serde_json::from_str(&contents).map_err(|e| {
421
+ DockerError::Container(format!("Failed to parse user record {path}: {e}"))
422
+ })?;
423
+ records.push(record);
424
+ }
425
+
426
+ Ok(records)
427
+ }
428
+
429
+ fn user_record_path(username: &str) -> String {
430
+ format!("{USERS_STORE_DIR}/{username}.json")
431
+ }
432
+
433
+ async fn write_user_record(
434
+ client: &DockerClient,
435
+ container: &str,
436
+ record: &PersistedUserRecord,
437
+ ) -> Result<(), DockerError> {
438
+ let payload =
439
+ serde_json::to_string_pretty(record).map_err(|e| DockerError::Container(e.to_string()))?;
440
+ let record_path = user_record_path(&record.username);
441
+ let write_command =
442
+ format!("install -d -m 700 {USERS_STORE_DIR} && umask 077 && cat > {record_path}");
443
+ let cmd = vec!["sh", "-c", write_command.as_str()];
444
+ exec_command_with_stdin(client, container, cmd, &payload).await?;
445
+ Ok(())
446
+ }
447
+
259
448
  /// Parsed user info from /etc/passwd line (intermediate struct)
260
449
  struct ParsedUser {
261
450
  username: String,
@@ -23,13 +23,17 @@ pub const VOLUME_PROJECTS: &str = "opencode-workspace";
23
23
  /// Volume name for opencode configuration
24
24
  pub const VOLUME_CONFIG: &str = "opencode-config";
25
25
 
26
+ /// Volume name for persisted user records
27
+ pub const VOLUME_USERS: &str = "opencode-users";
28
+
26
29
  /// All volume names as array for iteration
27
- pub const VOLUME_NAMES: [&str; 5] = [
30
+ pub const VOLUME_NAMES: [&str; 6] = [
28
31
  VOLUME_SESSION,
29
32
  VOLUME_STATE,
30
33
  VOLUME_CACHE,
31
34
  VOLUME_PROJECTS,
32
35
  VOLUME_CONFIG,
36
+ VOLUME_USERS,
33
37
  ];
34
38
 
35
39
  /// Mount point for opencode data inside container
@@ -47,6 +51,9 @@ pub const MOUNT_PROJECTS: &str = "/home/opencode/workspace";
47
51
  /// Mount point for configuration inside container
48
52
  pub const MOUNT_CONFIG: &str = "/home/opencode/.config/opencode";
49
53
 
54
+ /// Mount point for persisted user records inside container
55
+ pub const MOUNT_USERS: &str = "/var/lib/opencode-users";
56
+
50
57
  /// Ensure all required volumes exist
51
58
  ///
52
59
  /// Creates volumes if they don't exist. This operation is idempotent -
@@ -145,16 +152,18 @@ mod tests {
145
152
  assert_eq!(VOLUME_CACHE, "opencode-cache");
146
153
  assert_eq!(VOLUME_PROJECTS, "opencode-workspace");
147
154
  assert_eq!(VOLUME_CONFIG, "opencode-config");
155
+ assert_eq!(VOLUME_USERS, "opencode-users");
148
156
  }
149
157
 
150
158
  #[test]
151
159
  fn volume_names_array_has_all_volumes() {
152
- assert_eq!(VOLUME_NAMES.len(), 5);
160
+ assert_eq!(VOLUME_NAMES.len(), 6);
153
161
  assert!(VOLUME_NAMES.contains(&VOLUME_SESSION));
154
162
  assert!(VOLUME_NAMES.contains(&VOLUME_STATE));
155
163
  assert!(VOLUME_NAMES.contains(&VOLUME_CACHE));
156
164
  assert!(VOLUME_NAMES.contains(&VOLUME_PROJECTS));
157
165
  assert!(VOLUME_NAMES.contains(&VOLUME_CONFIG));
166
+ assert!(VOLUME_NAMES.contains(&VOLUME_USERS));
158
167
  }
159
168
 
160
169
  #[test]
@@ -164,5 +173,6 @@ mod tests {
164
173
  assert_eq!(MOUNT_CACHE, "/home/opencode/.cache/opencode");
165
174
  assert_eq!(MOUNT_PROJECTS, "/home/opencode/workspace");
166
175
  assert_eq!(MOUNT_CONFIG, "/home/opencode/.config/opencode");
176
+ assert_eq!(MOUNT_USERS, "/var/lib/opencode-users");
167
177
  }
168
178
  }