|
| 1 | +--- |
| 2 | +phase: 17-custom-bind-mounts |
| 3 | +plan: 01 |
| 4 | +type: execute |
| 5 | +wave: 1 |
| 6 | +depends_on: [] |
| 7 | +files_modified: |
| 8 | + - packages/core/src/config/schema.rs |
| 9 | + - packages/core/src/docker/mount.rs |
| 10 | + - packages/core/src/docker/mod.rs |
| 11 | +autonomous: true |
| 12 | + |
| 13 | +must_haves: |
| 14 | + truths: |
| 15 | + - "Mount string can be parsed into host path, container path, and read-only flag" |
| 16 | + - "Invalid mount formats are rejected with clear error messages" |
| 17 | + - "Relative paths are rejected with actionable error" |
| 18 | + - "Non-existent paths are rejected with clear error" |
| 19 | + - "Symlinks are resolved to their target directories" |
| 20 | + - "Config stores mounts as array of strings" |
| 21 | + artifacts: |
| 22 | + - path: "packages/core/src/docker/mount.rs" |
| 23 | + provides: "Mount parsing, validation, and Bollard conversion" |
| 24 | + exports: ["ParsedMount", "MountError", "validate_mount_path", "check_container_path_warning"] |
| 25 | + - path: "packages/core/src/config/schema.rs" |
| 26 | + provides: "Config mounts field" |
| 27 | + contains: "pub mounts: Vec<String>" |
| 28 | + key_links: |
| 29 | + - from: "packages/core/src/docker/mount.rs" |
| 30 | + to: "bollard::service::Mount" |
| 31 | + via: "to_bollard_mount() method" |
| 32 | + pattern: "MountTypeEnum::BIND" |
| 33 | +--- |
| 34 | + |
| 35 | +<objective> |
| 36 | +Create the core mount parsing and validation module with config schema extension. |
| 37 | + |
| 38 | +Purpose: Establish the foundation for bind mount support with proper validation before container integration. |
| 39 | +Output: `mount.rs` module with ParsedMount struct, MountError enum, validation functions, and config schema with `mounts: Vec<String>` field. |
| 40 | +</objective> |
| 41 | + |
| 42 | +<execution_context> |
| 43 | +@~/.claude/get-shit-done/workflows/execute-plan.md |
| 44 | +@~/.claude/get-shit-done/templates/summary.md |
| 45 | +</execution_context> |
| 46 | + |
| 47 | +<context> |
| 48 | +@.planning/PROJECT.md |
| 49 | +@.planning/ROADMAP.md |
| 50 | +@.planning/STATE.md |
| 51 | +@.planning/phases/17-custom-bind-mounts/17-CONTEXT.md |
| 52 | +@.planning/phases/17-custom-bind-mounts/17-RESEARCH.md |
| 53 | +@packages/core/src/config/schema.rs |
| 54 | +@packages/core/src/docker/mod.rs |
| 55 | +@packages/core/src/docker/error.rs |
| 56 | +</context> |
| 57 | + |
| 58 | +<tasks> |
| 59 | + |
| 60 | +<task type="auto"> |
| 61 | + <name>Task 1: Create mount.rs module with parsing and validation</name> |
| 62 | + <files>packages/core/src/docker/mount.rs, packages/core/src/docker/mod.rs</files> |
| 63 | + <action> |
| 64 | +Create `packages/core/src/docker/mount.rs` with: |
| 65 | + |
| 66 | +1. **MountError enum** using thiserror: |
| 67 | + - `RelativePath(String)` - "Mount paths must be absolute. Use: /full/path/to/dir" |
| 68 | + - `InvalidFormat(String)` - "Invalid mount format. Expected: /host/path:/container/path[:ro]" |
| 69 | + - `PathNotFound(String, String)` - path and underlying error |
| 70 | + - `NotADirectory(String)` - "Path is not a directory" |
| 71 | + - `PermissionDenied(String)` - "Cannot access path" |
| 72 | + |
| 73 | +2. **ParsedMount struct**: |
| 74 | + ```rust |
| 75 | + pub struct ParsedMount { |
| 76 | + pub host_path: PathBuf, |
| 77 | + pub container_path: String, |
| 78 | + pub read_only: bool, |
| 79 | + } |
| 80 | + ``` |
| 81 | + |
| 82 | +3. **ParsedMount::parse()** method: |
| 83 | + - Split mount string by `:` (handle 2 or 3 parts) |
| 84 | + - Part 1: host path (must be absolute) |
| 85 | + - Part 2: container path |
| 86 | + - Part 3 (optional): "ro" or "rw" (default rw) |
| 87 | + - Return MountError::InvalidFormat for wrong number of parts |
| 88 | + - Return MountError::RelativePath if host path not absolute |
| 89 | + |
| 90 | +4. **ParsedMount::to_bollard_mount()** method: |
| 91 | + ```rust |
| 92 | + pub fn to_bollard_mount(&self) -> bollard::service::Mount { |
| 93 | + Mount { |
| 94 | + target: Some(self.container_path.clone()), |
| 95 | + source: Some(self.host_path.to_string_lossy().to_string()), |
| 96 | + typ: Some(MountTypeEnum::BIND), |
| 97 | + read_only: Some(self.read_only), |
| 98 | + ..Default::default() |
| 99 | + } |
| 100 | + } |
| 101 | + ``` |
| 102 | + |
| 103 | +5. **validate_mount_path()** function: |
| 104 | + - Check `is_absolute()` - return MountError::RelativePath |
| 105 | + - Call `std::fs::canonicalize()` - return MountError::PathNotFound on error |
| 106 | + - Call `std::fs::metadata()` - return MountError::PathNotFound or PermissionDenied |
| 107 | + - Check `metadata.is_dir()` - return MountError::NotADirectory |
| 108 | + - Return Ok(canonical_path) |
| 109 | + |
| 110 | +6. **check_container_path_warning()** function: |
| 111 | + - SYSTEM_PATHS constant: `/etc`, `/usr`, `/bin`, `/sbin`, `/lib`, `/var` |
| 112 | + - Return Some(warning_string) if container_path starts with any system path |
| 113 | + - Return None otherwise |
| 114 | + |
| 115 | +7. **Unit tests**: |
| 116 | + - `parse_valid_mount_rw` - "/a:/b" -> host=/a, container=/b, read_only=false |
| 117 | + - `parse_valid_mount_ro` - "/a:/b:ro" -> read_only=true |
| 118 | + - `parse_valid_mount_explicit_rw` - "/a:/b:rw" -> read_only=false |
| 119 | + - `parse_invalid_format_single_part` - "invalid" -> InvalidFormat |
| 120 | + - `parse_invalid_format_too_many_parts` - "/a:/b:ro:extra" -> InvalidFormat |
| 121 | + - `parse_relative_path_rejected` - "./rel:/b" -> RelativePath |
| 122 | + - `system_path_warning` - "/etc/passwd" triggers warning |
| 123 | + - `non_system_path_no_warning` - "/workspace/data" no warning |
| 124 | + |
| 125 | +Update `packages/core/src/docker/mod.rs`: |
| 126 | +- Add `pub mod mount;` |
| 127 | +- Add `pub use mount::{MountError, ParsedMount, check_container_path_warning, validate_mount_path};` |
| 128 | + </action> |
| 129 | + <verify> |
| 130 | +Run `cargo build -p opencode-cloud-core` to verify compilation. |
| 131 | +Run `cargo test -p opencode-cloud-core mount::` to verify tests pass. |
| 132 | + </verify> |
| 133 | + <done> |
| 134 | +mount.rs exists with ParsedMount, MountError, validate_mount_path, check_container_path_warning. All unit tests pass. Module exported from docker/mod.rs. |
| 135 | + </done> |
| 136 | +</task> |
| 137 | + |
| 138 | +<task type="auto"> |
| 139 | + <name>Task 2: Add mounts field to config schema</name> |
| 140 | + <files>packages/core/src/config/schema.rs</files> |
| 141 | + <action> |
| 142 | +In `packages/core/src/config/schema.rs`: |
| 143 | + |
| 144 | +1. Add `mounts` field to Config struct after `update_check`: |
| 145 | + ```rust |
| 146 | + /// Bind mounts to apply when starting the container |
| 147 | + /// Format: ["/host/path:/container/path", "/host:/mnt:ro"] |
| 148 | + #[serde(default)] |
| 149 | + pub mounts: Vec<String>, |
| 150 | + ``` |
| 151 | + |
| 152 | +2. Update `Default for Config` impl: |
| 153 | + - Add `mounts: Vec::new(),` in the default initialization |
| 154 | + |
| 155 | +3. Add tests: |
| 156 | + - `test_default_config_mounts_field` - default config has empty mounts vec |
| 157 | + - `test_serialize_deserialize_with_mounts` - roundtrip with mounts array |
| 158 | + - `test_mounts_field_default_on_missing` - old configs without mounts get empty vec |
| 159 | + |
| 160 | +Ensure existing tests still pass (the deny_unknown_fields attribute should not affect new fields we add). |
| 161 | + </action> |
| 162 | + <verify> |
| 163 | +Run `cargo test -p opencode-cloud-core config::` to verify all config tests pass. |
| 164 | +Run `cargo build -p opencode-cloud-core` to verify compilation. |
| 165 | + </verify> |
| 166 | + <done> |
| 167 | +Config struct has `mounts: Vec<String>` field with serde default. All existing and new config tests pass. |
| 168 | + </done> |
| 169 | +</task> |
| 170 | + |
| 171 | +</tasks> |
| 172 | + |
| 173 | +<verification> |
| 174 | +1. `cargo build --all-targets --all-features` passes |
| 175 | +2. `cargo test --all-features` passes |
| 176 | +3. `cargo clippy --all-targets --all-features -- -D warnings` passes |
| 177 | +4. ParsedMount can parse "/host:/container", "/host:/container:ro", "/host:/container:rw" |
| 178 | +5. Validation rejects relative paths, non-existent paths, non-directories |
| 179 | +6. Config loads and saves with mounts array |
| 180 | +</verification> |
| 181 | + |
| 182 | +<success_criteria> |
| 183 | +- Mount parsing works for all valid formats (2-part rw, 3-part ro/rw) |
| 184 | +- Validation catches all error cases with clear messages |
| 185 | +- Config schema accepts mounts array and handles old configs gracefully |
| 186 | +- All tests pass, no clippy warnings |
| 187 | +</success_criteria> |
| 188 | + |
| 189 | +<output> |
| 190 | +After completion, create `.planning/phases/17-custom-bind-mounts/17-01-SUMMARY.md` |
| 191 | +</output> |
0 commit comments