From dfc6886e5a45dbf4c9adedce2bf4ac248e1085b6 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Mon, 7 Sep 2026 21:54:57 +0500 Subject: [PATCH] feat: feat: preserve and restore complete native setups Published at 0.0.68. Propose changes through this repository's issues and pull requests. --- CHANGELOG.md | 16 + Cargo.lock | 8 +- Cargo.toml | 8 +- README.md | 2 +- crates/harness-runtime/src/facts.rs | 38 ++ crates/harness-runtime/src/human.rs | 15 + crates/harness-runtime/src/lib.rs | 5 +- crates/harness-runtime/src/wire.rs | 594 +++++++++++++++++--- crates/opencode-setup-system/src/main.rs | 18 +- crates/provider-v3/src/argv.rs | 7 + crates/provider-v3/src/info.rs | 1 + crates/provider-v3/src/plan.rs | 23 + crates/setup-core/src/backup.rs | 223 ++++++-- crates/setup-core/src/lib.rs | 1 + crates/setup-core/src/native_snapshot.rs | 464 +++++++++++++++ install.ps1 | 2 +- install.sh | 2 +- provider-kit/v3/KIT-IDENTITY.json | 4 +- provider-kit/v3/SHA256SUMS | 6 +- provider-kit/v3/manifest.json | 2 +- provider-kit/v3/provider-info.schema.json | 1 + provider-kit/v3/status-response.schema.json | 54 ++ 22 files changed, 1359 insertions(+), 135 deletions(-) create mode 100644 crates/setup-core/src/native_snapshot.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a5f350..6072716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,22 @@ cut and that this clone does not carry. ## [Unreleased] +## [0.0.68] - 2026-09-07 + +Complete native setup preservation captures user additions, installed plugin +state and measured configuration outside portable installation ownership. +Returning to a saved setup first preserves current edits, then restores exact +covered bytes, empty directories and supported permissions. Complete backup +format 2 prevents legacy readers from misinterpreting the coverage base. + +The OpenCode provider retains prior setup identity and written ownership. +Prepared recovery restores previous provider metadata even after an interrupted +state write. Complete snapshots remain held against rolling retention and status +reports verified recovery integrity and current native-state comparison. + +This release uses consumer kit 0.2.11. Install a compatible released ai-stp CLI +reader before using the new provider declaration. + ## [0.0.67] - 2026-09-07 nddev-builder creates complete native tool collections: select and author diff --git a/Cargo.lock b/Cargo.lock index 8ffc326..b299e06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,7 +66,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "harness-runtime" -version = "0.0.67" +version = "0.0.68" dependencies = [ "provider-v3", "serde", @@ -128,7 +128,7 @@ dependencies = [ [[package]] name = "opencode-setup-system" -version = "0.0.67" +version = "0.0.68" dependencies = [ "harness-runtime", "provider-v3", @@ -147,7 +147,7 @@ dependencies = [ [[package]] name = "provider-v3" -version = "0.0.67" +version = "0.0.68" dependencies = [ "serde", "serde_json", @@ -209,7 +209,7 @@ dependencies = [ [[package]] name = "setup-core" -version = "0.0.67" +version = "0.0.68" dependencies = [ "miniz_oxide", "serde", diff --git a/Cargo.toml b/Cargo.toml index 18c9f0f..5d7f456 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.0.67" +version = "0.0.68" edition = "2024" rust-version = "1.89" license = "AGPL-3.0-or-later" @@ -23,9 +23,9 @@ sha2 = "0.11" # `setup-core::archive`); an inflate loop is not, because its bugs are # memory-safety bugs and it is not improved by being hand-written here. miniz_oxide = "0.9" -setup-core = { path = "crates/setup-core", version = "0.0.67" } -provider-v3 = { path = "crates/provider-v3", version = "0.0.67" } -harness-runtime = { path = "crates/harness-runtime", version = "0.0.67" } +setup-core = { path = "crates/setup-core", version = "0.0.68" } +provider-v3 = { path = "crates/provider-v3", version = "0.0.68" } +harness-runtime = { path = "crates/harness-runtime", version = "0.0.68" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index c7c20f4..03cd142 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ release is a convenience, not the authorised copy. ```bash docker run --rm -v "$HOME/.config:/config" \ - ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.67 \ + ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.68 \ status --target /config/ --json ``` diff --git a/crates/harness-runtime/src/facts.rs b/crates/harness-runtime/src/facts.rs index 0d39559..632cf60 100644 --- a/crates/harness-runtime/src/facts.rs +++ b/crates/harness-runtime/src/facts.rs @@ -174,6 +174,10 @@ pub struct Harness { /// Excluded from backups so a slot never holds credentials, and excluded /// from target identity so the product's own traffic cannot strand a plan. pub never_touch: &'static [&'static str], + /// Complete preservation surfaces that differ from installation ownership. + /// These may preserve product-managed plugin bytes without making them + /// writable destinations for portable component installation. + pub preservation_surfaces: &'static [PreservationSurface], /// What a *neighbour's* configuration home looks like from inside a target. /// /// Every command here takes an explicit `--target` because a change aimed at @@ -336,6 +340,17 @@ pub const BACKUP_SLOTS: usize = 10; /// The bundle format every setup system reads. pub const BUNDLE_FORMAT: &str = "ai-stp-bundle/2"; +/// A native configuration cover used only for explicit complete preservation. +#[derive(Debug, Clone, Copy)] +pub struct PreservationSurface { + /// The target scope this surface describes. + pub scope: Option, + /// All covered target-relative configuration roots. + pub roots: &'static [&'static str], + /// Credential and runtime paths never copied or restored. + pub excluded: &'static [&'static str], +} + impl Harness { /// Whether one relative path falls inside a namespace this harness claims. /// @@ -524,6 +539,28 @@ impl Harness { names } + /// Complete native coverage is independent of portable installation routes. + #[must_use] + pub fn preservation_surface( + &self, + scope: Option, + ) -> (Vec<&'static str>, Vec<&'static str>) { + let mut roots = self.owned_projection(scope).to_vec(); + let mut excluded = self.never_captured(); + if let Some(surface) = self + .preservation_surfaces + .iter() + .find(|surface| surface.scope == scope) + { + roots.extend_from_slice(surface.roots); + excluded = vec![self.control_directory]; + excluded.extend_from_slice(surface.excluded); + } + roots.sort_unstable(); + roots.dedup(); + (roots, excluded) + } + /// A digest of this build's own manifest. /// /// The contract is explicit that the release digest must not come from @@ -921,6 +958,7 @@ mod tests { native_namespaces: &["AGENTS.md", "settings.json", "skills"], shadowing_names: &[], custody_namespaces: &[], + preservation_surfaces: &[], never_touch: &[".credentials.json", "sessions"], foreign_homes: &[], permission_profiles: &["default"], diff --git a/crates/harness-runtime/src/human.rs b/crates/harness-runtime/src/human.rs index f878c82..a5b9e56 100644 --- a/crates/harness-runtime/src/human.rs +++ b/crates/harness-runtime/src/human.rs @@ -1105,6 +1105,20 @@ fn mutate( (effect, None) }; + let control = resolved.ensure_control_directory()?; + let pool = Pool::open(&control, facts::BACKUP_SLOTS)?; + let native_capture = wire::plan_native_capture( + harness, + &resolved, + HUMAN_SCOPE, + operation, + None, + match &effect { + Effect::Restore { backup_ref } => backup_ref.as_deref(), + _ => None, + }, + &pool, + )?; let artifact = PlanArtifact::new(PlanInputs { // No scope: the human surface is a person at a terminal, and a // scope is something a consumer resolves. Omitted rather than @@ -1128,6 +1142,7 @@ fn mutate( _ => None, }, restore_target_digest, + native_capture, permission_profile: None, expires_at: &expiry::deadline_in(PLAN_WINDOW_SECONDS, SystemTime::now()), // The human surface drives configuration, never the product's own diff --git a/crates/harness-runtime/src/lib.rs b/crates/harness-runtime/src/lib.rs index b90d4f2..9e27700 100644 --- a/crates/harness-runtime/src/lib.rs +++ b/crates/harness-runtime/src/lib.rs @@ -46,7 +46,10 @@ pub use catalog::{Catalog, Setup}; // The software types belong to the kernel, but a setup system declares its // artifact table and depends only on this crate. Re-exported so that stays // true rather than widening seven dependency lists to reach past it. -pub use facts::{BACKUP_SLOTS, BUNDLE_FORMAT, Foreign, Harness, LaunchBinding, Scoped, Shadow}; +pub use facts::{ + BACKUP_SLOTS, BUNDLE_FORMAT, Foreign, Harness, LaunchBinding, PreservationSurface, Scoped, + Shadow, +}; pub use setup_core::software::{Artifact, Delivery, Previous, Shape, Software}; /// The kernel's content digest, re-exported for the seven binaries. diff --git a/crates/harness-runtime/src/wire.rs b/crates/harness-runtime/src/wire.rs index 8e755aa..92399d4 100644 --- a/crates/harness-runtime/src/wire.rs +++ b/crates/harness-runtime/src/wire.rs @@ -23,10 +23,11 @@ use std::time::SystemTime; use provider_v3::argv::{Bundle as ArgvBundle, Invocation, PlanRequest}; use provider_v3::bundle::{Bundle, Claim, FILES_PREFIX}; -use provider_v3::plan::{EndState, PlanArtifact, PlanInputs}; +use provider_v3::plan::{EndState, NativeCapture, PlanArtifact, PlanInputs}; use provider_v3::{Error, Operation, Result, WireReason}; use setup_core::backup::{BackupRef, Pool, SLOT_SCHEMA, SlotRecord}; use setup_core::journal::{JOURNAL_SCHEMA, Journal, Phase}; +use setup_core::native_snapshot::{NativeBase, NativeSnapshot}; use setup_core::stamp::{DriftState, ProviderState, STATE_SCHEMA, StateReading}; use setup_core::target::Target; use setup_core::{digest, lock}; @@ -327,7 +328,66 @@ fn status( let owned = owned_here(harness, &resolved, scope)?; let identity = resolved.identity_of_owned(&as_paths(&owned), &harness.not_our_identity())?; let journal = Journal::read(&control).ok().flatten(); - status_of(harness, &resolved, &pool, &identity, journal) + status_of(harness, &resolved, &pool, &identity, journal, scope) +} + +fn backup_status( + pool: &Pool, + resolved: &Target, + harness: &Harness, + scope: Option, +) -> Result { + let held = pool.held()?; + let records = pool.list()?; + // One current-state observation serves every retained snapshot comparison. + let current = if records + .iter() + .any(|record| record.native_snapshot.is_some()) + { + inspect_native_surface(harness, resolved, scope) + .ok() + .map(|(_, snapshot)| snapshot) + } else { + None + }; + let mut entries = Vec::new(); + for record in records { + let holder = held + .iter() + .find(|(reference, _)| *reference == record.backup_ref); + let mut entry = serde_json::json!({ + "backup_ref": record.backup_ref.as_str(), + "operation": record.operation, + "setup_id": record.setup_id, + "held": holder.is_some(), + "hold_reason": holder.map(|(_, reason)| reason.clone()), + }); + if let Some(snapshot) = &record.native_snapshot { + let verification = if pool.payload_of(&record.backup_ref).is_ok() { + "verified" + } else { + "unavailable" + }; + let target_state = current.as_ref().map_or("unavailable", |current| { + if current == snapshot { + "matches" + } else { + "differs" + } + }); + entry["native_snapshot"] = serde_json::json!({ + "digest": snapshot.digest()?, + "base_root": snapshot.base_root, + "operation_id": record.operation_id, + "roots": snapshot.roots, + "excluded": snapshot.excluded, + "verification": verification, + "target_state": target_state, + }); + } + entries.push(entry); + } + Ok(entries.into()) } /// Which scope `status` measures a target under. @@ -369,6 +429,7 @@ fn status_of( pool: &Pool, identity: &str, journal: Option, + scope: Option, ) -> Result { let reading = ProviderState::read(resolved.root(), harness.state_file)?; // `managed` carries our state; `unmanaged` holds content that is not ours; @@ -475,38 +536,7 @@ fn status_of( None => serde_json::Value::Null, }, ), - ("backups", { - // A hold is the difference between a reference a plan can rely - // on and one retention may take out from under it. The pool has - // known which slots are held since 0.0.6; `status` did not say, - // so a consumer could only find out by watching a baseline - // disappear after fifty captures -- which is the failure the - // hold exists to prevent, discovered the same way. - // - // Read here rather than in the map below because `held` walks - // the pool once; asking per slot would be one walk per slot. - let held = pool.held()?; - pool.list()? - .iter() - .map(|record| { - let holder = held - .iter() - .find(|(reference, _)| *reference == record.backup_ref); - serde_json::json!({ - "backup_ref": record.backup_ref.as_str(), - "operation": record.operation, - "setup_id": record.setup_id, - "held": holder.is_some(), - // The reason, not only the fact. A caller deciding - // whether it may release one needs to know whose - // baseline it would be taking, which is exactly what - // the refusal on `hold` already says. - "hold_reason": holder.map(|(_, reason)| reason.clone()), - }) - }) - .collect::>() - .into() - }), + ("backups", backup_status(pool, resolved, harness, scope)?), ] { answer.insert(key.to_owned(), value); } @@ -768,13 +798,26 @@ fn plan(harness: &Harness, target: &Path, request: &PlanRequest) -> Result Result Result Result Result<()> { fn restore_target_identity( harness: &Harness, payload: &Path, - _scope: Option, + record: &SlotRecord, + target: &Target, ) -> Result { - let owned = files_in_payload(payload)?; + let complete = record.native_snapshot.as_ref(); + let owned = if complete.is_some() { + record.previous_written_paths.clone().unwrap_or_default() + } else { + files_in_payload(payload)? + }; + let identity_root = if complete.is_some_and(|snapshot| snapshot.base_root == NativeBase::Parent) + { + payload.join(target.root().file_name().ok_or_else(|| { + Error::refuse( + WireReason::UnsupportedNativeSurface, + "the native target has no leaf directory", + ) + })?) + } else { + payload.to_path_buf() + }; Ok(setup_core::digest::of_owned( - payload, + &identity_root, &as_paths(&owned), &harness.not_our_identity(), )?) } +/// A closed cover; the parent is used only for Claude's documented global companion. +fn inspect_native_surface( + harness: &Harness, + target: &Target, + scope: Option, +) -> Result<(std::path::PathBuf, NativeSnapshot)> { + let (roots, excluded) = harness.preservation_surface(scope); + if harness.harness_id == "claude-code" + && scope.is_none() + && target + .root() + .file_name() + .is_some_and(|name| name == ".claude") + { + let root = target.root().parent().ok_or_else(|| { + Error::refuse( + WireReason::UnsupportedNativeSurface, + "the Claude target has no companion directory", + ) + })?; + let mut roots: Vec = roots.iter().map(|path| format!(".claude/{path}")).collect(); + roots.push(".claude.json".to_owned()); + let excluded: Vec = excluded + .iter() + .map(|path| format!(".claude/{path}")) + .collect(); + let mut snapshot = NativeSnapshot::inspect(root, &as_paths(&roots), &as_paths(&excluded))?; + snapshot.base_root = NativeBase::Parent; + Ok((root.to_path_buf(), snapshot)) + } else { + Ok(( + target.root().to_path_buf(), + NativeSnapshot::inspect(target.root(), &roots, &excluded)?, + )) + } +} + +/// Bind explicit complete preservation or restoration of a complete snapshot. +pub(crate) fn plan_native_capture( + harness: &Harness, + target: &Target, + scope: Option, + operation: Operation, + capture_mode: Option<&str>, + backup_ref: Option<&str>, + pool: &Pool, +) -> Result> { + if capture_mode.is_some() + && (capture_mode != Some("complete_native") + || !matches!( + operation, + Operation::Backup + | Operation::Install + | Operation::Replace + | Operation::Remove + | Operation::Reset + )) + { + return Err(Error::refuse( + WireReason::UnsupportedOperation, + "capture-mode complete_native is supported only for native configuration mutations", + )); + } + let restore = if operation == Operation::Restore { + let record = chosen_backup(pool, backup_ref)?; + pool.payload_of(&record.backup_ref)?; + record.native_snapshot + } else { + None + }; + if capture_mode.is_none() && restore.is_none() { + return Ok(None); + } + let (_, current) = inspect_native_surface(harness, target, scope)?; + if let Some(saved) = &restore + && (saved.base_root != current.base_root + || saved.roots != current.roots + || saved.excluded != current.excluded) + { + return Err(Error::refuse( + WireReason::UnsupportedNativeSurface, + "the saved native surface differs from this provider's declared coverage", + )); + } + Ok(Some(NativeCapture { + base_root: current.base_root, + current_digest: current.digest()?, + restore_digest: restore.map(|snapshot| snapshot.digest()).transpose()?, + roots: current.roots, + excluded: current.excluded, + })) +} + /// Every regular payload file, relative to the payload root. fn files_in_payload(payload: &Path) -> Result> { let mut found = Vec::new(); @@ -1615,19 +1777,52 @@ pub(crate) fn perform( // Re-check after the lock: everything observed before it could have moved. let owned = owned_here(harness, &resolved, mutation.target_scope)?; - let identity_paths = snapshot_if_unmanaged_backup( + let identity_paths = if mutation.provenance.get("native_capture").is_some() { + owned.clone() + } else { + snapshot_if_unmanaged_backup( + harness, + &resolved, + mutation.target_scope, + &owned, + mutation.operation, + )? + }; + let identity = + resolved.identity_of_owned(&as_paths(&identity_paths), &harness.not_our_identity())?; + if identity != mutation.expected_target_digest { + return Err(Error::refuse( + WireReason::Stale, + "the target changed after the lock was taken; no effect was made", + )); + } + let native_capture: Option = mutation + .provenance + .get("native_capture") + .map(|value| serde_json::from_value(value.clone())) + .transpose() + .map_err(|_| Error::refuse(WireReason::Stale, "invalid native capture binding"))?; + let restoring = match &mutation.effect { + Effect::Restore { backup_ref } => backup_ref.as_deref(), + _ => None, + }; + let native_expected = plan_native_capture( harness, &resolved, mutation.target_scope, - &owned, mutation.operation, + if native_capture.is_some() && mutation.operation != Operation::Restore { + Some("complete_native") + } else { + None + }, + restoring, + &pool, )?; - let identity = - resolved.identity_of_owned(&as_paths(&identity_paths), &harness.not_our_identity())?; - if identity != mutation.expected_target_digest { + if native_capture != native_expected { return Err(Error::refuse( WireReason::Stale, - "the target changed after the lock was taken; no effect was made", + "complete native state changed after planning; no effect was made", )); } setup_core::journal::require_clean_for_planning( @@ -1655,17 +1850,49 @@ pub(crate) fn perform( )?; refuse_uncapturable(&resolved, &capture)?; refuse_an_unrecorded_removal(harness, &resolved, mutation)?; - let captured = pool.capture(resolved.root(), &as_paths(&capture), |backup_ref| { - SlotRecord { - schema_version: SLOT_SCHEMA, - backup_ref, - operation: operation_name.clone(), - operation_id: operation_id.clone(), - target_identity_digest: identity.clone(), - setup_id: previous_setup.clone(), - setup_definition_digest: previous_definition.clone(), + let previous_provider_state = match ProviderState::read(resolved.root(), harness.state_file)? { + StateReading::Current(state) => Some(state), + _ => None, + }; + let record_capture = |backup_ref| SlotRecord { + schema_version: SLOT_SCHEMA, + backup_ref, + operation: operation_name.clone(), + operation_id: operation_id.clone(), + target_identity_digest: identity.clone(), + setup_id: previous_setup.clone(), + setup_definition_digest: previous_definition.clone(), + native_snapshot: None, + previous_written_paths: Some(previous_written.clone()), + previous_provider_state: previous_provider_state.clone(), + }; + let selected_hold = if native_capture.is_some() { + restoring + .map(|reference| { + let reference = BackupRef::parse(reference)?; + let added = pool.hold( + &reference, + &format!("restore operation {}", mutation.operation_id), + )?; + Ok::<_, Error>((reference, added)) + }) + .transpose()? + } else { + None + }; + let captured = if let Some(binding) = &native_capture { + let (source_root, snapshot) = + inspect_native_surface(harness, &resolved, mutation.target_scope)?; + if snapshot.digest()? != binding.current_digest { + return Err(Error::refuse( + WireReason::Stale, + "native state changed before capture; no target effect was made", + )); } - })?; + pool.capture_native(&source_root, &snapshot, record_capture)? + } else { + pool.capture(resolved.root(), &as_paths(&capture), record_capture)? + }; let journal = Journal { schema_version: JOURNAL_SCHEMA, @@ -1692,7 +1919,12 @@ pub(crate) fn perform( // means "the files this provider has written at this target", not "the // files this operation wrote", and an operation that writes none leaves // it as it found it. - Effect::Backup => Ok(previous_written.clone()), + Effect::Backup => { + if let Some(state) = &previous_provider_state { + copy_applied_identity(&mut applied, state); + } + Ok(previous_written.clone()) + } Effect::Restore { backup_ref } => { let record = chosen_backup(&pool, backup_ref.as_deref())?; let payload = pool.payload_of(&record.backup_ref)?; @@ -1703,7 +1935,17 @@ pub(crate) fn perform( applied .setup_definition_digest .clone_from(&record.setup_definition_digest); - replace_managed_from(harness, &resolved, &payload, mutation.target_scope, false) + if let Some(state) = &record.previous_provider_state { + copy_applied_identity(&mut applied, state); + } + if let Some(snapshot) = &record.native_snapshot { + let (source_root, _) = + inspect_native_surface(harness, &resolved, mutation.target_scope)?; + snapshot.restore(&payload, &source_root)?; + Ok(record.previous_written_paths.clone().unwrap_or_default()) + } else { + replace_managed_from(harness, &resolved, &payload, mutation.target_scope, false) + } } // Removal puts nothing on the target and leaves nothing of ours there, // so an empty list is the true answer rather than a missing one -- @@ -1755,6 +1997,9 @@ pub(crate) fn perform( )?; journal.promote_to_committed(&control)?; Journal::clear(&control)?; + if let Some((reference, true)) = selected_hold { + pool.release(&reference)?; + } Ok(serde_json::json!({ "state": "verified", @@ -1882,10 +2127,12 @@ fn recover(harness: &Harness, target: &Path) -> Result { let _guard = setup_core::lock::TargetLock::acquire(&control)?; let Some(journal) = Journal::read(&control)? else { + let quarantined = pool.quarantine_partial()?; return Ok(serde_json::json!({ "state": "verified", - "recovered": false, - "detail": "no journal is published; there is nothing to resolve", + "recovered": quarantined != 0, + "detail": if quarantined == 0 { "no journal is published; there is nothing to resolve" } + else { "incomplete captures were retained in the backup archive; the target was not changed" }, })); }; @@ -1910,13 +2157,53 @@ fn recover(harness: &Harness, target: &Path) -> Result { }; let backup_ref = BackupRef::parse(reference)?; let payload = pool.payload_of(&backup_ref)?; - replace_managed_from(harness, &resolved, &payload, scope, false)?; + let record = chosen_backup(&pool, Some(reference))?; + if let Some(snapshot) = &record.native_snapshot { + let (source_root, current) = inspect_native_surface(harness, &resolved, scope)?; + if current.base_root != snapshot.base_root + || current.roots != snapshot.roots + || current.excluded != snapshot.excluded + { + return Err(Error::refuse( + WireReason::RecoveryRequired, + "native recovery surface differs from this provider", + )); + } + if let Some(state) = &record.previous_provider_state + && (state.canonical_target != resolved.root().to_string_lossy() + || state.provider_id != harness.provider_id + || state.harness_id != harness.harness_id) + { + return Err(Error::refuse( + WireReason::RecoveryRequired, + "saved provider metadata belongs to another target", + )); + } + snapshot.restore(&payload, &source_root)?; + if let Some(state) = &record.previous_provider_state { + state.write(resolved.root(), harness.state_file)?; + } else if record.previous_written_paths.is_some() { + let state_path = resolved.root().join(harness.state_file); + if state_path.exists() { + fs::remove_file(&state_path).map_err(|error| { + Error::refuse( + WireReason::RecoveryRequired, + format!("cannot restore absent provider metadata: {error}"), + ) + })?; + } + } + } else { + replace_managed_from(harness, &resolved, &payload, scope, false)?; + } + let owned = owned_here(harness, &resolved, scope)?; Journal::clear(&control)?; Ok(serde_json::json!({ "state": "verified", "recovered": true, "phase": Phase::Prepared.as_str(), "restored_from": reference, + "target_digest": resolved.identity_of_owned(&as_paths(&owned), &harness.not_our_identity())?, "target_identity_digest": resolved.identity_of_owned(&as_paths(&owned), &harness.not_our_identity())?, })) } @@ -1927,6 +2214,7 @@ fn recover(harness: &Harness, target: &Path) -> Result { "state": "verified", "recovered": true, "phase": Phase::Committed.as_str(), + "target_digest": resolved.identity_of_owned(&as_paths(&owned), &harness.not_our_identity())?, "target_identity_digest": resolved.identity_of_owned(&as_paths(&owned), &harness.not_our_identity())?, })) } @@ -2719,6 +3007,22 @@ fn refuse_uncapturable(resolved: &Target, owned: &[String]) -> Result<()> { )) } +/// Preserve setup identity independently of the new operation provenance. +fn copy_applied_identity(applied: &mut Applied, state: &ProviderState) { + applied.setup_id.clone_from(&state.setup_stable_id); + applied.setup_version.clone_from(&state.setup_version); + applied + .setup_version_passport_digest + .clone_from(&state.setup_version_passport_digest); + applied + .setup_definition_digest + .clone_from(&state.setup_definition_digest); + applied.component_refs.clone_from(&state.component_refs); + applied.bundle_format.clone_from(&state.bundle_format); + applied.bundle_digest.clone_from(&state.bundle_digest); + applied.artifact_digest.clone_from(&state.artifact_digest); +} + /// Record what this operation leaves behind, as the contract asks it to. /// /// Takes the whole [`Mutation`] rather than the two fields it needs from it. @@ -2982,6 +3286,7 @@ pub(crate) mod tests_support { native_namespaces: &["AGENTS.md", "settings.json", "skills"], shadowing_names: &[], custody_namespaces: &[], + preservation_surfaces: &[], never_touch: &[".credentials.json", "sessions"], foreign_homes: &[], permission_profiles: &["default"], @@ -5030,6 +5335,83 @@ mod tests { assert_eq!(status["provider_state"]["drift_state"], "clean"); } + #[test] + fn complete_native_return_preserves_user_additions_and_restores_empty_directories() { + let target = seeded("complete-return"); + // A prior provider operation must not narrow a later complete capture. + plan_then_apply(&target, "backup", &[]); + fs::create_dir_all(target.join("skills/empty")).unwrap(); + fs::write(target.join("skills/user.py"), b"print('user')\n").unwrap(); + let baseline = + NativeSnapshot::inspect(&target, TEST.native_namespaces, &TEST.never_captured()) + .unwrap(); + let saved = plan_then_apply(&target, "backup", &["--capture-mode", "complete_native"]); + let reference = saved["backup_ref"].as_str().unwrap(); + fs::write(target.join("skills/new.sh"), b"echo new\n").unwrap(); + fs::write(target.join("AGENTS.md"), b"new instructions").unwrap(); + fs::remove_dir(target.join("skills/empty")).unwrap(); + let edited = + NativeSnapshot::inspect(&target, TEST.native_namespaces, &TEST.never_captured()) + .unwrap(); + let restored = plan_then_apply(&target, "restore", &["--backup-ref", reference]); + assert_eq!(restored["state"], "verified"); + baseline.verify(&target).unwrap(); + assert!(!target.join("skills/new.sh").exists()); + assert_eq!(fs::read(target.join("unrelated.txt")).unwrap(), b"keep me"); + let edited_ref = restored["backup_ref"].as_str().unwrap(); + plan_then_apply(&target, "restore", &["--backup-ref", edited_ref]); + edited.verify(&target).unwrap(); + } + + #[test] + fn complete_native_capture_rejects_changes_outside_the_written_inventory() { + let target = seeded("complete-stale"); + plan_then_apply(&target, "backup", &[]); + let planned = run(args( + "plan-operation", + &target, + &[ + "--operation", + "backup", + "--capture-mode", + "complete_native", + "--provider-release-digest", + RELEASE, + "--operation-id", + "operation_01NATIVE", + "--expires-at", + far_future(), + ], + )); + let path = target.parent().unwrap().join("native-plan.json"); + fs::write( + &path, + setup_core::canonical::to_canonical_bytes(&planned["plan"]).unwrap(), + ) + .unwrap(); + fs::write(target.join("skills/new.py"), b"user addition").unwrap(); + let error = refuse(args( + "apply-operation", + &target, + &[ + "--plan", + &path.to_string_lossy(), + "--plan-digest", + planned["plan_digest"].as_str().unwrap(), + "--provider-release-digest", + RELEASE, + ], + )); + assert_eq!(error.reason(), Some(WireReason::Stale)); + assert_eq!( + run(args("status", &target, &[]))["backups"] + .as_array() + .unwrap() + .len(), + 1 + ); + } + #[test] fn a_backup_never_copies_product_owned_credentials() { let target = seeded("no-secrets"); @@ -5368,6 +5750,67 @@ mod tests { assert_eq!(planned["state"], "planned"); } + #[test] + fn complete_native_recovery_rewinds_a_state_written_before_journal_commit() { + for managed in [false, true] { + let target = seeded(if managed { + "recover-native-managed" + } else { + "recover-native-unmanaged" + }); + if managed { + plan_then_apply(&target, "backup", &[]); + let StateReading::Current(mut state) = + ProviderState::read(&target, TEST.state_file).unwrap() + else { + panic!("state missing"); + }; + state.setup_version = Some("1.7".to_owned()); + state.component_refs = vec!["component_original@1.0".to_owned()]; + state.write(&target, TEST.state_file).unwrap(); + } + let state_path = target.join(TEST.state_file); + let original_state = fs::read(&state_path).ok(); + let original = fs::read(target.join("AGENTS.md")).unwrap(); + let saved = plan_then_apply(&target, "backup", &["--capture-mode", "complete_native"]); + let StateReading::Current(mut state) = + ProviderState::read(&target, TEST.state_file).unwrap() + else { + panic!("state missing"); + }; + if managed { + assert_eq!(state.setup_version.as_deref(), Some("1.7")); + assert_eq!(state.component_refs, vec!["component_original@1.0"]); + } + state.setup_version = Some("9.9".to_owned()); + state.written_paths = vec!["skills/unrecorded".to_owned()]; + state.write(&target, TEST.state_file).unwrap(); + fs::write(target.join("AGENTS.md"), b"half-written").unwrap(); + fs::write(target.join("skills/unrecorded"), b"partial new file").unwrap(); + Journal { + schema_version: JOURNAL_SCHEMA, + phase: Phase::Prepared, + operation_id: state.operation_id, + operation: "backup".to_owned(), + plan_digest: saved["plan_digest"].as_str().unwrap().to_owned(), + target_precondition_digest: saved["expected_target_digest"] + .as_str() + .unwrap() + .to_owned(), + backup_ref: Some(saved["backup_ref"].as_str().unwrap().to_owned()), + target_scope: None, + } + .publish_prepared(&target.join(TEST.control_directory)) + .unwrap(); + let recovered = run(args("recover-operation", &target, &[])); + assert_eq!(recovered["state"], "verified"); + assert_eq!(recovered["target_digest"], saved["expected_target_digest"]); + assert_eq!(fs::read(&state_path).ok(), original_state); + assert_eq!(fs::read(target.join("AGENTS.md")).unwrap(), original); + assert!(!target.join("skills/unrecorded").exists()); + } + } + #[test] fn recovery_with_no_journal_says_so_rather_than_inventing_work() { let target = seeded("recover-clean"); @@ -5377,6 +5820,31 @@ mod tests { ); } + #[test] + fn recovery_retains_unpublished_captures_and_unblocks_future_plans() { + let target = seeded("recover-unpublished"); + let (_, control, pool) = open(&TEST, &target).unwrap(); + let partial = control + .join(setup_core::backup::POOL_DIRECTORY_NAME) + .join("slot-000000000001"); + fs::create_dir_all(partial.join("payload")).unwrap(); + fs::write(partial.join("payload/AGENTS.md"), b"interrupted copy").unwrap(); + let original = fs::read(target.join("AGENTS.md")).unwrap(); + assert_eq!( + run(args("recover-operation", &target, &[]))["recovered"], + true + ); + assert!(pool.partial_slots().unwrap().is_empty()); + assert_eq!(fs::read(target.join("AGENTS.md")).unwrap(), original); + assert_eq!( + fs::read(control.join("backups/incomplete/slot-000000000001-0/payload/AGENTS.md")) + .unwrap(), + b"interrupted copy" + ); + plan_then_apply(&target, "backup", &[]); + assert_eq!(pool.list().unwrap().len(), 1); + } + #[test] fn a_restore_plan_names_the_target_it_will_produce() { let target = seeded("restore-shape"); diff --git a/crates/opencode-setup-system/src/main.rs b/crates/opencode-setup-system/src/main.rs index aed5897..22abff2 100644 --- a/crates/opencode-setup-system/src/main.rs +++ b/crates/opencode-setup-system/src/main.rs @@ -14,7 +14,7 @@ use std::process::ExitCode; mod software; -use harness_runtime::{Harness, LaunchBinding, Scoped, Shadow}; +use harness_runtime::{Harness, LaunchBinding, PreservationSurface, Scoped, Shadow}; use provider_v3::{ComponentKind, ProjectionKind, TargetScope}; /// Everything specific to OpenCode, verified against `opencode-baseline.json`. @@ -115,6 +115,22 @@ pub const OPENCODE: Harness = Harness { // them -- every posture agrees there is nothing, which makes the // emptiness a statement none of them made. custody_namespaces: &["tui.json"], + preservation_surfaces: &[PreservationSurface { + scope: None, + roots: &[ + "opencode.jsonc", + "tui.jsonc", + "agent", + "command", + "skill", + "plugin", + "package.json", + "package-lock.json", + "bun.lock", + "node_modules", + ], + excluded: &["auth.json", "cache"], + }], never_touch: &["auth.json", "cache"], // No near neighbour measured for this product. A marker listed here is a // refusal waiting to happen, so nothing is listed without evidence. diff --git a/crates/provider-v3/src/argv.rs b/crates/provider-v3/src/argv.rs index 5457be0..aa95412 100644 --- a/crates/provider-v3/src/argv.rs +++ b/crates/provider-v3/src/argv.rs @@ -101,6 +101,10 @@ const fn plan_usage(command: Command) -> Usage { "where a program lives; required by every software_* operation", ), ("--backup-ref", "which slot a restore returns to"), + ( + "--capture-mode", + "complete_native captures the entire declared native surface", + ), ("--permission-profile", "a profile this build declares"), ( "--software-version", @@ -319,6 +323,8 @@ pub struct PlanRequest { pub expires_at: String, /// The backup this operation reads or writes. pub backup_ref: Option, + /// Explicit complete preservation; absent retains recorded-file backup behavior. + pub capture_mode: Option, /// The permission profile to apply. pub permission_profile: Option, /// The bundle, when the operation carries one. @@ -509,6 +515,7 @@ where operation_id: flags.take_required("--operation-id")?, expires_at: flags.take_required("--expires-at")?, backup_ref: flags.take_optional("--backup-ref"), + capture_mode: flags.take_optional("--capture-mode"), permission_profile: flags.take_optional("--permission-profile"), bundle: flags.take_bundle()?, prefix: flags.take_prefix()?, diff --git a/crates/provider-v3/src/info.rs b/crates/provider-v3/src/info.rs index b3e4ad4..b94fda9 100644 --- a/crates/provider-v3/src/info.rs +++ b/crates/provider-v3/src/info.rs @@ -408,6 +408,7 @@ impl ProviderInfo { plan_request_fields: vec![ TargetScope::REQUEST_FIELD.to_owned(), EndState::REQUEST_FIELD.to_owned(), + "capture_mode".to_owned(), ], // Declared 2026-09-02 in the same order as the two above: kit 0.2.9 // names the member (`provider-info.schema.json`), ai-stp-cli diff --git a/crates/provider-v3/src/plan.rs b/crates/provider-v3/src/plan.rs index 6d30a99..597abec 100644 --- a/crates/provider-v3/src/plan.rs +++ b/crates/provider-v3/src/plan.rs @@ -153,6 +153,22 @@ impl EndState { } } +/// The provider's immutable description of one effect. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NativeCapture { + /// Explicit base of the complete cover, never an arbitrary ancestor. + #[serde(default)] + pub base_root: setup_core::native_snapshot::NativeBase, + /// Complete native namespace coverage, including shared configuration. + pub roots: Vec, + /// Product-owned paths outside the effect. + pub excluded: Vec, + /// Complete current-state precondition, including permission metadata. + pub current_digest: String, + /// Complete restored-state identity when returning to a preserved setup. + pub restore_digest: Option, +} + /// The provider's immutable description of one effect. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct PlanArtifact { @@ -184,6 +200,9 @@ pub struct PlanArtifact { pub backup_ref: Option, /// The target identity a restore will produce. Restore only. pub restore_target_digest: Option, + /// Complete native preservation, explicitly requested or inherited from a snapshot. + #[serde(skip_serializing_if = "Option::is_none")] + pub native_capture: Option, /// The permission profile to apply, when one was requested. pub permission_profile: Option, /// The scope the consumer resolved this target to be, when it said. @@ -268,6 +287,8 @@ pub struct PlanInputs<'a> { pub backup_ref: Option, /// The identity a restore will produce. Required for restore, refused otherwise. pub restore_target_digest: Option, + /// Complete native preservation binding when requested. + pub native_capture: Option, /// The permission profile, when one was requested. pub permission_profile: Option, /// The scope the consumer resolved this target to be, when it said. @@ -389,6 +410,7 @@ impl PlanArtifact { bundle: inputs.bundle, backup_ref: inputs.backup_ref, restore_target_digest: inputs.restore_target_digest, + native_capture: inputs.native_capture, permission_profile: inputs.permission_profile, platform: platform::echo(), expires_at: inputs.expires_at.to_owned(), @@ -542,6 +564,7 @@ mod tests { bundle: Some(binding()), backup_ref: Some("slot-000000000001".to_owned()), restore_target_digest: None, + native_capture: None, permission_profile: Some("default".to_owned()), expires_at: "2026-08-23T15:00:00Z", effects: vec!["write settings.json".to_owned()], diff --git a/crates/setup-core/src/backup.rs b/crates/setup-core/src/backup.rs index 48c47d9..3bacfb3 100644 --- a/crates/setup-core/src/backup.rs +++ b/crates/setup-core/src/backup.rs @@ -47,6 +47,9 @@ pub const SLOT_HELD_NAME: &str = "HELD"; /// The schema this kernel writes and is willing to read. pub const SLOT_SCHEMA: u32 = 1; +/// Complete native snapshots require a reader that understands their coverage. +pub const NATIVE_SLOT_SCHEMA: u32 = 2; + /// A target-bound reference to one backup slot. /// /// The reference is meaningful only against the target it was captured from; @@ -122,6 +125,15 @@ pub struct SlotRecord { /// refusing to read one would trade a recoverable target for a field. #[serde(default)] pub setup_definition_digest: Option, + /// Verified complete native coverage, absent for write-only backups. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_snapshot: Option, + /// Installation ownership before capture, independent of preserved coverage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_written_paths: Option>, + /// Exact pre-operation provider metadata for complete recovery. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_provider_state: Option>, } /// The bounded pool of backup slots for one target. @@ -154,6 +166,17 @@ impl Pool { ) .with_source(source) })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).map_err(|source| { + Error::new( + ReasonCode::StateUnavailable, + "cannot protect local recovery storage", + ) + .with_source(source) + })?; + } Ok(Self { root, capacity }) } @@ -226,6 +249,53 @@ impl Pool { Ok(partial) } + /// Move incomplete captures aside without discarding their bytes. + /// + /// The caller must hold the target lock and establish that no journal is + /// published. An unpublished capture cannot have authorized target writes. + /// + /// # Errors + /// + /// Returns [`ReasonCode::StateUnavailable`] if listing or renaming fails. + pub fn quarantine_partial(&self) -> Result { + let partial = self.partial_slots()?; + if partial.is_empty() { + return Ok(0); + } + let archive = self.root.join("incomplete"); + fs::create_dir_all(&archive).map_err(|source| { + Error::new( + ReasonCode::StateUnavailable, + "cannot create incomplete capture archive", + ) + .with_source(source) + })?; + for slot in &partial { + let name = slot + .file_name() + .ok_or_else(|| { + Error::new(ReasonCode::IntegrityMismatch, "a capture slot has no name") + })? + .to_string_lossy(); + let mut sequence = 0_u64; + let destination = loop { + let candidate = archive.join(format!("{name}-{sequence}")); + if !candidate.exists() { + break candidate; + } + sequence += 1; + }; + fs::rename(slot, destination).map_err(|source| { + Error::new( + ReasonCode::StateUnavailable, + "cannot quarantine incomplete capture", + ) + .with_source(source) + })?; + } + Ok(partial.len()) + } + /// The payload directory of one completed slot. /// /// # Errors @@ -240,7 +310,23 @@ impl Pool { format!("backup {} is absent or incomplete", backup_ref.as_str()), )); } - Ok(slot.join(SLOT_PAYLOAD_NAME)) + let payload = slot.join(SLOT_PAYLOAD_NAME); + let record = read_record(&slot)?.ok_or_else(|| { + Error::new( + ReasonCode::IntegrityMismatch, + "backup completion record is absent", + ) + })?; + if record.backup_ref != *backup_ref { + return Err(Error::new( + ReasonCode::IntegrityMismatch, + "backup reference does not match its slot", + )); + } + if let Some(snapshot) = record.native_snapshot { + snapshot.verify(&payload)?; + } + Ok(payload) } /// The reference the next capture will use. @@ -328,7 +414,40 @@ impl Pool { } } - let record = record(backup_ref); + self.complete(&slot, record(backup_ref)) + } + + /// Capture and read back a complete native snapshot before publishing its marker. + /// + /// # Errors + /// Refuses changed source state, failed copies and verification or retention errors. + pub fn capture_native( + &self, + source: &Path, + snapshot: &crate::native_snapshot::NativeSnapshot, + record: impl FnOnce(BackupRef) -> SlotRecord, + ) -> Result { + let backup_ref = self.next_ref()?; + let slot = self.root.join(backup_ref.as_str()); + let payload = slot.join(SLOT_PAYLOAD_NAME); + fs::create_dir_all(&payload).map_err(|error| { + Error::new( + ReasonCode::StateUnavailable, + "cannot create native backup payload", + ) + .with_source(error) + })?; + snapshot.copy_to(source, &payload)?; + let mut record = record(backup_ref); + record.schema_version = NATIVE_SLOT_SCHEMA; + record.native_snapshot = Some(snapshot.clone()); + // Publish retention before completion: a completed preserved setup must + // never enter the reclaimable rolling window, even after a lost response. + lock::atomic_write(&slot.join(SLOT_HELD_NAME), b"preserved native setup")?; + self.complete(&slot, record) + } + + fn complete(&self, slot: &Path, record: SlotRecord) -> Result { let value = serde_json::to_value(&record).map_err(|source_error| { Error::new( ReasonCode::StateUnavailable, @@ -359,11 +478,8 @@ impl Pool { /// /// # Errors /// - /// Refuses a reference this pool does not hold, and refuses a hold that - /// would leave the pool no slot to rotate — ten held slots is a target that - /// can never be backed up again, which is a worse failure than the eviction - /// it was protecting against. The refusal names what is already held so a - /// caller knows what to release. + /// Refuses a reference this pool does not hold or an unreadable hold marker. + /// Held snapshots are outside the rolling capacity and remain until released. pub fn hold(&self, backup_ref: &BackupRef, reason: &str) -> Result { let slot = self.root.join(backup_ref.as_str()); if read_record(&slot)?.is_none() { @@ -379,24 +495,6 @@ impl Pool { if already.iter().any(|(held, _)| held == backup_ref) { return Ok(false); } - // One slot must stay reclaimable, or the next capture has nothing to - // evict and the pool grows past the bound it was opened with. - if already.len() + 1 >= self.capacity { - return Err(Error::new( - ReasonCode::InvalidTarget, - format!( - "holding {} would leave this pool of {} no slot to rotate; release one of \ - these first, and the reason each names is who would lose it: {}", - backup_ref.as_str(), - self.capacity, - already - .iter() - .map(|(held, why)| format!("{} ({why})", held.as_str())) - .collect::>() - .join(", ") - ), - )); - } // The reason travels with the hold. Without it a caller reading a full // pool knows what to release and not what releasing it would cost. fs::write(slot.join(SLOT_HELD_NAME), reason.as_bytes()).map_err(|source| { @@ -574,11 +672,16 @@ fn read_record(slot: &Path) -> Result> { ) .with_source(source) })?; - if record.schema_version != SLOT_SCHEMA { + let valid = match record.schema_version { + SLOT_SCHEMA => record.native_snapshot.is_none(), + NATIVE_SLOT_SCHEMA => record.native_snapshot.is_some(), + _ => false, + }; + if !valid { return Err(Error::new( ReasonCode::StateUnavailable, format!( - "backup schema {} is not the {SLOT_SCHEMA} this build writes", + "backup schema {} does not match a supported recovery record", record.schema_version ), )); @@ -805,9 +908,34 @@ mod tests { target_identity_digest: "sha256:target".to_owned(), setup_id: Some("full-auto".to_owned()), setup_definition_digest: Some("sha256:definition".to_owned()), + native_snapshot: None, + previous_written_paths: None, + previous_provider_state: None, } } + #[test] + fn complete_native_slots_verify_payload_integrity_on_read() { + let root = scratch("native-integrity"); + let source = root.join("source"); + fs::create_dir_all(source.join("skills/empty")).unwrap(); + fs::write(source.join("skills/tool.sh"), b"echo captured\n").unwrap(); + let snapshot = + crate::native_snapshot::NativeSnapshot::inspect(&source, &["skills"], &[]).unwrap(); + let pool = Pool::open(&root.join("control"), 3).unwrap(); + let record = pool.capture_native(&source, &snapshot, record_for).unwrap(); + assert_eq!(record.schema_version, NATIVE_SLOT_SCHEMA); + assert_eq!(record.native_snapshot, Some(snapshot)); + let payload = pool.payload_of(&record.backup_ref).unwrap(); + assert!(payload.join("skills/empty").is_dir()); + fs::write(payload.join("skills/tool.sh"), b"corrupted").unwrap(); + assert!(pool.payload_of(&record.backup_ref).is_err()); + assert_eq!( + fs::read(source.join("skills/tool.sh")).unwrap(), + b"echo captured\n" + ); + } + #[test] fn a_slot_written_before_the_definition_digest_existed_still_reads() { // The field was added without a schema bump, so a slot captured by an @@ -905,38 +1033,27 @@ mod tests { assert!(pool.payload_of(&baseline.backup_ref).is_err()); } - /// A pool that is entirely held is a target that can never be backed up - /// again, which is a worse failure than the eviction a hold prevents. #[test] - fn a_hold_that_would_leave_nothing_to_rotate_is_refused_naming_what_to_release() { + fn preserved_slots_do_not_consume_the_rolling_capacity() { let base = scratch("held-full"); let target = base.join("target"); fs::create_dir_all(&target).unwrap(); fs::write(target.join("a.txt"), "x").unwrap(); - let pool = Pool::open(&base.join("control"), 3).unwrap(); - let first = pool.capture(&target, &["a.txt"], record_for).unwrap(); - let second = pool.capture(&target, &["a.txt"], record_for).unwrap(); - let third = pool.capture(&target, &["a.txt"], record_for).unwrap(); - - assert!(pool.hold(&first.backup_ref, "series A baseline").unwrap()); - assert!(pool.hold(&second.backup_ref, "series B baseline").unwrap()); - // Holding a third of three would leave nothing to evict. - let error = pool.hold(&third.backup_ref, "series C").unwrap_err(); - assert!(error.to_string().contains("no slot to rotate"), "{error}"); - assert!( - error.to_string().contains(first.backup_ref.as_str()), - "the refusal does not say what to release: {error}" - ); - // And what releasing it would cost, so nobody releases blind. - assert!( - error.to_string().contains("series A baseline"), - "the refusal does not say who holds it: {error}" - ); - - // Holding one that is already held is not an error and not a second - // hold: a run that re-runs its own setup should not have to check. - assert!(!pool.hold(&first.backup_ref, "series A again").unwrap()); + let mut saved = Vec::new(); + for _ in 0..7 { + let record = pool.capture(&target, &["a.txt"], record_for).unwrap(); + assert!(pool.hold(&record.backup_ref, "saved setup").unwrap()); + saved.push(record.backup_ref); + } + for _ in 0..8 { + pool.capture(&target, &["a.txt"], record_for).unwrap(); + } + assert_eq!(pool.list().unwrap().len(), saved.len() + 3); + for reference in saved { + assert!(pool.payload_of(&reference).is_ok()); + assert!(!pool.hold(&reference, "retry").unwrap()); + } } /// A reference this pool never minted is refused rather than marked. diff --git a/crates/setup-core/src/lib.rs b/crates/setup-core/src/lib.rs index 1e3f7c6..f277754 100644 --- a/crates/setup-core/src/lib.rs +++ b/crates/setup-core/src/lib.rs @@ -25,6 +25,7 @@ pub mod digest; pub mod error; pub mod journal; pub mod lock; +pub mod native_snapshot; pub mod software; pub mod stamp; pub mod target; diff --git a/crates/setup-core/src/native_snapshot.rs b/crates/setup-core/src/native_snapshot.rs new file mode 100644 index 0000000..d5a62ca --- /dev/null +++ b/crates/setup-core/src/native_snapshot.rs @@ -0,0 +1,464 @@ +//! Complete local native configuration snapshots, separate from write ownership. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Component, Path}; + +use serde::{Deserialize, Serialize}; + +use crate::{Error, ReasonCode, Result}; + +/// Filesystem base of a declared preservation cover. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NativeBase { + /// Every covered path is relative to the provider target. + #[default] + Target, + /// A closed cover includes a documented companion beside the target. + Parent, +} + +impl NativeBase { + #[allow( + clippy::trivially_copy_pass_by_ref, + reason = "serde skip callback takes a reference" + )] + fn is_target(&self) -> bool { + *self == Self::Target + } +} + +/// The complete configuration surface and its measured members. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NativeSnapshot { + /// Base for the declared relative roots, included in the snapshot digest. + #[serde(default, skip_serializing_if = "NativeBase::is_target")] + pub base_root: NativeBase, + /// Target-relative native namespace roots, reduced to a non-overlapping cover. + pub roots: Vec, + /// Product-owned paths excluded from capture and replacement. + pub excluded: Vec, + /// Exact relative member paths and metadata; no raw configuration values. + pub entries: BTreeMap, +} + +/// A regular file or directory captured without following links. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Member { + /// File content digest; absent for a directory. + pub digest: Option, + /// Unix permission bits, or the read-only flag on other platforms. + pub permissions: u32, +} + +fn io_error(path: &Path, source: std::io::Error) -> Error { + Error::new( + ReasonCode::StateUnavailable, + format!("cannot access native snapshot member {}", path.display()), + ) + .with_source(source) +} + +fn within(path: &str, root: &str) -> bool { + path == root + || path + .strip_prefix(root) + .is_some_and(|rest| rest.starts_with('/')) +} + +fn validate_path(path: &str) -> Result<()> { + if path.is_empty() + || path.contains('\\') + || path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + || Path::new(path) + .components() + .any(|part| !matches!(part, Component::Normal(_))) + { + return Err(Error::new( + ReasonCode::IntegrityMismatch, + "invalid native snapshot relative path", + )); + } + Ok(()) +} + +fn permissions(metadata: &fs::Metadata) -> u32 { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o7777 + } + #[cfg(not(unix))] + { + u32::from(metadata.permissions().readonly()) + } +} + +fn set_permissions(path: &Path, mode: u32) -> Result<()> { + #[cfg(unix)] + let value = { + use std::os::unix::fs::PermissionsExt; + fs::Permissions::from_mode(mode) + }; + #[cfg(not(unix))] + let value = { + let mut value = fs::metadata(path) + .map_err(|error| io_error(path, error))? + .permissions(); + value.set_readonly(mode != 0); + value + }; + fs::set_permissions(path, value).map_err(|error| io_error(path, error)) +} + +impl NativeSnapshot { + /// Measure every member of a native surface without following filesystem links. + /// + /// # Errors + /// Refuses unsupported entries, unreadable members and invalid relative paths. + pub fn inspect(root: &Path, roots: &[&str], excluded: &[&str]) -> Result { + let mut roots: Vec = roots.iter().map(|path| (*path).to_owned()).collect(); + let mut excluded: Vec = excluded.iter().map(|path| (*path).to_owned()).collect(); + for path in roots.iter().chain(&excluded) { + validate_path(path)?; + } + roots.sort(); + roots.dedup(); + excluded.sort(); + excluded.dedup(); + let cover = roots + .iter() + .filter(|path| { + !roots + .iter() + .any(|ancestor| ancestor != *path && within(path, ancestor)) + && !excluded.iter().any(|skip| within(path, skip)) + }) + .cloned() + .collect(); + let mut snapshot = Self { + base_root: NativeBase::Target, + roots: cover, + excluded, + entries: BTreeMap::new(), + }; + for relative in snapshot.roots.clone() { + // A nested declaration must not cross a symlink in a transport parent. + let mut parent = root.to_path_buf(); + for component in Path::new(&relative).components() { + parent.push(component); + match fs::symlink_metadata(&parent) { + Ok(meta) if meta.is_symlink() => { + return Err(Error::new( + ReasonCode::IntegrityMismatch, + "native snapshot refuses symbolic links", + )); + } + Ok(_) => (), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, + Err(error) => return Err(io_error(&parent, error)), + } + } + snapshot.walk(root, &relative)?; + } + Ok(snapshot) + } + + fn walk(&mut self, root: &Path, relative: &str) -> Result<()> { + if self.excluded.iter().any(|skip| within(relative, skip)) { + return Ok(()); + } + let path = root.join(relative); + let metadata = match fs::symlink_metadata(&path) { + Ok(value) => value, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(io_error(&path, error)), + }; + if !metadata.is_dir() && !metadata.is_file() { + return Err(Error::new( + ReasonCode::IntegrityMismatch, + format!("native snapshot refuses unsupported entry {relative}"), + )); + } + // Transport directories of excluded runtime state cannot be removed or + // have their mode rewound. Their covered children still belong to the + // snapshot, but creation of excluded state must not create native drift. + if !metadata.is_dir() || !self.excluded.iter().any(|skip| within(skip, relative)) { + self.entries.insert( + relative.to_owned(), + Member { + digest: if metadata.is_file() { + Some(crate::digest::of_file(&path)?) + } else { + None + }, + permissions: permissions(&metadata), + }, + ); + } + if metadata.is_dir() { + for entry in fs::read_dir(&path).map_err(|error| io_error(&path, error))? { + let entry = entry.map_err(|error| io_error(&path, error))?; + let name = entry.file_name().into_string().map_err(|_| { + Error::new( + ReasonCode::IntegrityMismatch, + "native snapshot member name is not UTF-8", + ) + })?; + validate_path(&name)?; + self.walk(root, &format!("{relative}/{name}"))?; + } + } + Ok(()) + } + + /// A digest of coverage, member bytes and permission metadata. + /// + /// # Errors + /// Propagates canonical serialization failures. + pub fn digest(&self) -> Result { + let value = serde_json::to_value(self).map_err(|error| { + Error::new( + ReasonCode::IntegrityMismatch, + "cannot encode native snapshot", + ) + .with_source(error) + })?; + crate::digest::of_domain_canonical_json("nddev.native-snapshot.v1", &value) + } + + /// Remeasure the whole surface and require an exact match. + /// + /// # Errors + /// Refuses missing, extra, changed, unreadable or unsupported members. + pub fn verify(&self, root: &Path) -> Result<()> { + let mut measured = Self::inspect( + root, + &self.roots.iter().map(String::as_str).collect::>(), + &self.excluded.iter().map(String::as_str).collect::>(), + )?; + measured.base_root = self.base_root; + if measured != *self { + return Err(Error::new( + ReasonCode::IntegrityMismatch, + "native snapshot inventory does not match", + )); + } + Ok(()) + } + + /// Copy measured members and preserve supported file and directory permissions. + /// + /// # Errors + /// Refuses changed source state and failed copies or readback verification. + pub fn copy_to(&self, source: &Path, destination: &Path) -> Result<()> { + self.verify(source)?; + for (relative, member) in &self.entries { + let from = source.join(relative); + let to = destination.join(relative); + if member.digest.is_none() { + fs::create_dir_all(&to).map_err(|error| io_error(&to, error))?; + } else { + if let Some(parent) = to.parent() { + fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; + } + fs::copy(&from, &to).map_err(|error| io_error(&to, error))?; + set_permissions(&to, member.permissions)?; + } + } + // Restrictive directory modes apply after their children are copied. + for (relative, member) in self.entries.iter().rev() { + if member.digest.is_none() { + set_permissions(&destination.join(relative), member.permissions)?; + } + } + self.verify(source)?; + self.verify(destination) + } + + /// Replace the covered surface after a caller has preserved its current state. + /// + /// # Errors + /// Refuses invalid recovery bytes before deletion and verifies the final state. + pub fn restore(&self, payload: &Path, target: &Path) -> Result<()> { + self.verify(payload)?; + let current = Self::inspect( + target, + &self.roots.iter().map(String::as_str).collect::>(), + &self.excluded.iter().map(String::as_str).collect::>(), + )?; + for (relative, member) in current.entries.iter().rev() { + let path = target.join(relative); + if member.digest.is_none() { + match fs::remove_dir(&path) { + Ok(()) => (), + Err(error) + if error.kind() == std::io::ErrorKind::DirectoryNotEmpty + && self.excluded.iter().any(|skip| within(skip, relative)) => {} + Err(error) => return Err(io_error(&path, error)), + } + } else { + fs::remove_file(&path).map_err(|error| io_error(&path, error))?; + } + } + self.copy_to(payload, target) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::panic)] + + use super::*; + + fn scratch(name: &str) -> std::path::PathBuf { + let path = + std::env::temp_dir().join(format!("native-snapshot-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + fn restore_returns_complete_state_and_preserves_the_state_it_replaces() { + let root = scratch("return"); + let target = root.join("target"); + let original = root.join("original"); + let edited = root.join("edited"); + fs::create_dir_all(target.join("skills/empty")).unwrap(); + fs::write(target.join("skills/run.py"), b"print('original')\n").unwrap(); + fs::write(target.join("config.json"), b"{\"x\":1}\n").unwrap(); + fs::write(target.join("project.txt"), b"outside").unwrap(); + let baseline = NativeSnapshot::inspect(&target, &["skills", "config.json"], &[]).unwrap(); + fs::create_dir_all(&original).unwrap(); + baseline.copy_to(&target, &original).unwrap(); + fs::write(target.join("config.json"), b"changed").unwrap(); + fs::write(target.join("skills/user-added.sh"), b"echo user\n").unwrap(); + fs::remove_dir(target.join("skills/empty")).unwrap(); + let current = NativeSnapshot::inspect(&target, &["skills", "config.json"], &[]).unwrap(); + fs::create_dir_all(&edited).unwrap(); + current.copy_to(&target, &edited).unwrap(); + baseline.restore(&original, &target).unwrap(); + baseline.verify(&target).unwrap(); + assert!(target.join("skills/empty").is_dir()); + assert!(!target.join("skills/user-added.sh").exists()); + assert_eq!(fs::read(target.join("project.txt")).unwrap(), b"outside"); + current.restore(&edited, &target).unwrap(); + current.verify(&target).unwrap(); + fs::write(target.join("skills/unexpected"), b"extra").unwrap(); + assert!(current.verify(&target).is_err()); + } + + #[test] + fn runtime_creation_does_not_change_an_empty_native_surface() { + let root = scratch("runtime-parent"); + let target = root.join("target"); + let payload = root.join("payload"); + fs::create_dir_all(&target).unwrap(); + fs::create_dir_all(&payload).unwrap(); + let empty = NativeSnapshot::inspect(&target, &["plugins"], &["plugins/data"]).unwrap(); + empty.copy_to(&target, &payload).unwrap(); + fs::create_dir_all(target.join("plugins/data")).unwrap(); + fs::write(target.join("plugins/data/session"), b"current runtime").unwrap(); + empty.verify(&target).unwrap(); + fs::create_dir_all(target.join("plugins/cache/new")).unwrap(); + fs::write(target.join("plugins/cache/new/code.js"), b"new config").unwrap(); + assert!(empty.verify(&target).is_err()); + empty.restore(&payload, &target).unwrap(); + empty.verify(&target).unwrap(); + assert_eq!( + fs::read(target.join("plugins/data/session")).unwrap(), + b"current runtime" + ); + assert!(!target.join("plugins/cache").exists()); + } + + #[test] + fn empty_original_removes_later_native_configuration() { + let root = scratch("empty"); + let target = root.join("target"); + let payload = root.join("payload"); + fs::create_dir_all(&target).unwrap(); + fs::create_dir_all(&payload).unwrap(); + let original = NativeSnapshot::inspect(&target, &["skills", "config.json"], &[]).unwrap(); + original.copy_to(&target, &payload).unwrap(); + fs::create_dir_all(target.join("skills/new")).unwrap(); + fs::write(target.join("config.json"), b"new").unwrap(); + original.restore(&payload, &target).unwrap(); + original.verify(&target).unwrap(); + assert_eq!(fs::read_dir(&target).unwrap().count(), 0); + } + + #[test] + fn corrupt_payload_is_refused_before_target_changes() { + let root = scratch("corrupt"); + let target = root.join("target"); + let payload = root.join("payload"); + fs::create_dir_all(&target).unwrap(); + fs::create_dir_all(&payload).unwrap(); + fs::write(target.join("config.json"), b"original").unwrap(); + let snapshot = NativeSnapshot::inspect(&target, &["config.json"], &[]).unwrap(); + snapshot.copy_to(&target, &payload).unwrap(); + fs::write(payload.join("config.json"), b"corrupt").unwrap(); + fs::write(target.join("config.json"), b"user edit").unwrap(); + assert!(snapshot.restore(&payload, &target).is_err()); + assert_eq!(fs::read(target.join("config.json")).unwrap(), b"user edit"); + } + + #[test] + fn never_touch_files_are_neither_copied_nor_replaced() { + let root = scratch("excluded"); + let target = root.join("target"); + let payload = root.join("payload"); + fs::create_dir_all(target.join("skills")).unwrap(); + fs::create_dir_all(&payload).unwrap(); + fs::write(target.join("skills/session.json"), b"product-owned").unwrap(); + fs::write(target.join("skills/a.md"), b"component").unwrap(); + let snapshot = + NativeSnapshot::inspect(&target, &["skills"], &["skills/session.json"]).unwrap(); + snapshot.copy_to(&target, &payload).unwrap(); + assert!(!payload.join("skills/session.json").exists()); + fs::write(target.join("skills/session.json"), b"updated by product").unwrap(); + fs::write(target.join("skills/new.md"), b"new").unwrap(); + snapshot.restore(&payload, &target).unwrap(); + assert_eq!( + fs::read(target.join("skills/session.json")).unwrap(), + b"updated by product" + ); + assert!(!target.join("skills/new.md").exists()); + } + + #[cfg(unix)] + #[test] + fn permissions_are_restored_and_part_of_preconditions() { + let root = scratch("permissions"); + let target = root.join("target"); + let payload = root.join("payload"); + fs::create_dir_all(target.join("skills/empty")).unwrap(); + fs::create_dir_all(&payload).unwrap(); + fs::write(target.join("skills/tool"), b"tool").unwrap(); + set_permissions(&target.join("skills/tool"), 0o750).unwrap(); + set_permissions(&target.join("skills/empty"), 0o700).unwrap(); + let snapshot = NativeSnapshot::inspect(&target, &["skills"], &[]).unwrap(); + snapshot.copy_to(&target, &payload).unwrap(); + set_permissions(&target.join("skills/tool"), 0o700).unwrap(); + assert!(snapshot.verify(&target).is_err()); + snapshot.restore(&payload, &target).unwrap(); + snapshot.verify(&target).unwrap(); + } + + #[cfg(unix)] + #[test] + fn links_and_linked_transport_parents_are_refused() { + let root = scratch("links"); + fs::create_dir_all(root.join("outside")).unwrap(); + fs::write(root.join("outside/tool"), b"foreign").unwrap(); + std::os::unix::fs::symlink(root.join("outside"), root.join("skills")).unwrap(); + assert!(NativeSnapshot::inspect(&root, &["skills"], &[]).is_err()); + assert!(NativeSnapshot::inspect(&root, &["skills/tool"], &[]).is_err()); + assert!(NativeSnapshot::inspect(&root, &["../outside"], &[]).is_err()); + } +} diff --git a/install.ps1 b/install.ps1 index cf07f8a..6cc5b74 100644 --- a/install.ps1 +++ b/install.ps1 @@ -7,7 +7,7 @@ # powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.1.0 [CmdletBinding()] param( - [string]$Version = "0.0.67", + [string]$Version = "0.0.68", [string]$InstallDir = "$env:LOCALAPPDATA\Programs\opencode-setup-system" ) $ErrorActionPreference = "Stop" diff --git a/install.sh b/install.sh index e7b458b..bb6aa7c 100644 --- a/install.sh +++ b/install.sh @@ -14,7 +14,7 @@ set -eu REPO="NDDev-OpenNetwork/opencode-setup-system" BINARY="opencode-setup-system" -VERSION="${1:-0.0.67}" +VERSION="${1:-0.0.68}" PREFIX="${OPENCODE_INSTALL_DIR:-$HOME/.local/bin}" case "$(uname -s)" in diff --git a/provider-kit/v3/KIT-IDENTITY.json b/provider-kit/v3/KIT-IDENTITY.json index 4e46a75..ca45c47 100644 --- a/provider-kit/v3/KIT-IDENTITY.json +++ b/provider-kit/v3/KIT-IDENTITY.json @@ -1,12 +1,12 @@ { - "aggregate_digest": "sha256:0578235bbec7bb23c6643651b6f105826d3a05a01bea0bd0cffac940b9cea4f3", + "aggregate_digest": "sha256:6ab195790be1c7dfacf6e6ab5a892244097cc2002f97090ff9a4b3c3174ed357", "files": [ "conformance-cases.json", "manifest.json", "provider-info.schema.json", "status-response.schema.json" ], - "kit_version": "0.2.10", + "kit_version": "0.2.11", "protocol_version": 3, "schema": "ai-stp-provider-kit-identity/1" } diff --git a/provider-kit/v3/SHA256SUMS b/provider-kit/v3/SHA256SUMS index 83a729b..6f9f240 100644 --- a/provider-kit/v3/SHA256SUMS +++ b/provider-kit/v3/SHA256SUMS @@ -1,4 +1,4 @@ fe04d03b15cfe8d5b61835eafd3ea788074684e2be69d419f9c22a37ea0461b7 conformance-cases.json -7dbec741b60de09250e11a9b144eccb73445c029db8f82797c8f9c62cf22b36b manifest.json -9df9ceb257fb421713678e8d516ac4f7095df87d675c74801d6cc2cf4e8f24ee provider-info.schema.json -2c5e01df3b02369832b74842b128c42b6b84d1e1455db3800474e54e71b645e4 status-response.schema.json +ae323ef26648fbaf441aa179008d0899ec79af88b3ae1e738e5bc75b1bbc8f30 manifest.json +64514da0c61012b5323987d72ee75f5ce8d6451ed557331a634b17aeac8d8966 provider-info.schema.json +7e67760f24bbc42a1242dad7ad3b378eef7ae15023c6813a33d582dfd2232293 status-response.schema.json diff --git a/provider-kit/v3/manifest.json b/provider-kit/v3/manifest.json index a191b09..9a4afcc 100644 --- a/provider-kit/v3/manifest.json +++ b/provider-kit/v3/manifest.json @@ -39,7 +39,7 @@ ], "decision": "docs/adr/ADR-0061-capability-negotiated-provider-protocol-v3.md", "generated_from": "apps/cli/src/ai_stp_cli/provider/protocol_v3.py", - "kit_version": "0.2.10", + "kit_version": "0.2.11", "operation_network": { "backup": [ { diff --git a/provider-kit/v3/provider-info.schema.json b/provider-kit/v3/provider-info.schema.json index 128b324..53ba00d 100644 --- a/provider-kit/v3/provider-info.schema.json +++ b/provider-kit/v3/provider-info.schema.json @@ -18,6 +18,7 @@ "plan_request_fields": { "items": { "enum": [ + "capture_mode", "end_state", "target_scope" ], diff --git a/provider-kit/v3/status-response.schema.json b/provider-kit/v3/status-response.schema.json index f28f384..4f38c9c 100644 --- a/provider-kit/v3/status-response.schema.json +++ b/provider-kit/v3/status-response.schema.json @@ -136,6 +136,60 @@ } ] }, + "native_snapshot": { + "additionalProperties": false, + "properties": { + "base_root": { + "enum": [ + "target", + "parent" + ] + }, + "digest": { + "$ref": "#/$defs/digest" + }, + "excluded": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "operation_id": { + "minLength": 1, + "type": "string" + }, + "roots": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "target_state": { + "enum": [ + "matches", + "differs", + "unavailable" + ] + }, + "verification": { + "enum": [ + "verified", + "unavailable" + ] + } + }, + "required": [ + "digest", + "operation_id", + "roots", + "excluded", + "verification", + "target_state" + ], + "type": "object" + }, "operation": { "minLength": 1, "type": "string"