diff --git a/README.md b/README.md index cd473f5..b09395f 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,15 @@ agents, commands, hooks, MCP entries and settings together, in one step. > **Status: complete for the five core operations and the program lifecycle.** > > `install`, `replace`, `backup`, `restore` and `remove` all work, over the wire -> and from the local catalog. `launch` is optional in the contract and is not -> declared. +> and from the local catalog. > > The software lifecycle installs the product itself: a plan names the > exact bytes offline, whoever holds the network fetches them, and apply > verifies and installs with the network gone. +> +> `launch` starts the exact executable that install placed, never a name +> found on `PATH`, and points the product at the target through the +> environment variable its own documentation names. ## Using it @@ -72,7 +75,8 @@ where the capability is declared. The vocabulary is owned by `SHA256SUMS`. **Human.** `list`, `status`, `install`, `reinstall`, `select`, `backups`, -`restore [--backup ]`, `remove`, `diff`. +`restore [--backup ]`, `remove`, `diff`, and `adopt` where a target may +still carry a stamp from the estate that came before this one. Both go through `crates/setup-core`. A human command that reached the target directly would bypass the guarantees the wire surface owes its consumer, so it diff --git a/SUPPORT.md b/SUPPORT.md index 561edb5..e0ce287 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -18,15 +18,19 @@ Never open a public issue for a vulnerability, and never paste credentials, tokens, or the contents of a backup slot anywhere in this repository. A backup slot holds whatever the target held when it was captured. -## What is not supported +## What this build does, and what it does not The software lifecycle — installing, updating and removing the product itself — is declared and does work. `plan` names the exact bytes offline, whoever holds the network fetches them, and `apply` verifies and installs with the network gone. -`launch` is optional in the provider contract and is not declared here. A -provider that advertised an operation it cannot perform would let a caller ask +`launch` is declared. It starts the exact executable a software install +placed under `--prefix`, never a name found on `PATH`, and points the +product at `--target` through the environment variable its own +documentation names. + +A provider that advertised an operation it cannot perform would let a caller ask for something that cannot be honoured, which is worse than not offering it. All five core operations do work: `backup`, `restore`, `remove`, `install` and diff --git a/crates/harness-runtime/src/adopt.rs b/crates/harness-runtime/src/adopt.rs new file mode 100644 index 0000000..ea273a6 --- /dev/null +++ b/crates/harness-runtime/src/adopt.rs @@ -0,0 +1,204 @@ +//! Taking over a target the frozen estate still claims. +//! +//! Before these seven there was `nddev-harnesses`, a Python estate whose module +//! for a product wrote a stamp file beside the configuration it managed. Some of +//! those files are still on disk. This build writes `NDDEV--PROVIDER.json` +//! and reads only that, so such a target reports `unmanaged`, an install leaves +//! both files, and the old program then sees drift in a directory it no longer +//! owns. +//! +//! Adoption ends that, and it is a command someone types. An install that +//! quietly took over a file this program never wrote would be worse than the +//! honest coexistence, because the person who ran it would not know it had +//! happened. +//! +//! Nothing is deleted. The old stamp is moved into this provider's own control +//! directory, which stops the old program recognising it and leaves the +//! pre-adoption state one `mv` away from being back. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use provider_v3::{Error, Result, WireReason}; +use serde::Deserialize; +use setup_core::{digest, target::Target}; + +use crate::facts::Harness; + +/// The schema the frozen estate's stamp files carry. +const PREDECESSOR_SCHEMA: u32 = 1; + +/// Where an adopted stamp is kept, inside the control directory. +const KEPT_IN: &str = "adopted"; + +/// One frozen-estate stamp, as much of it as adoption needs. +/// +/// Extra fields are ignored rather than refused: some modules wrote a +/// `content_setup_id` or a `source_setup_id` beside these, and a field this +/// build does not read is not a reason to refuse a file it otherwise +/// understands. +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct Predecessor { + /// The schema of the stamp. + pub schema_version: u32, + /// The estate module that wrote it. + pub product_name: String, + /// The build that wrote it. + pub build_version: String, + /// The setup it says is applied. + pub setup_id: String, + /// The directory it says it describes. + pub canonical_target: String, + /// Every file it claims, by target-relative path, with its `sha256` hex. + pub managed_files: BTreeMap, +} + +impl Predecessor { + /// The directory this stamp was written for, when that is not this one. + /// + /// Reported rather than refused. It was a refusal first, and a disposable + /// copy of a real estate-managed home — which is how this is tested, and + /// how someone moving a machine would meet it — could not be adopted at + /// all. The stamp's `canonical_target` is provenance, not authority over + /// what is on disk: every path it claims is relative, and every one of them + /// is checked against *this* target before anything is recorded. A stamp + /// carried somewhere unrelated simply accounts as missing. + pub(crate) fn written_elsewhere(&self, target: &Target) -> Option<&str> { + let here = target.root().to_string_lossy(); + (self.canonical_target != here).then_some(self.canonical_target.as_str()) + } +} + +/// What one claimed file turned out to be. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Claim { + /// Present, and the bytes are the ones the stamp recorded. + Intact, + /// Present, and the bytes are not. + Changed, + /// Named by the stamp and not on disk. + Missing, +} + +impl Claim { + /// The word a report uses. + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Intact => "intact", + Self::Changed => "changed", + Self::Missing => "missing", + } + } +} + +/// Read the predecessor's stamp, when this harness had one and it is there. +/// +/// # Errors +/// +/// Refuses a stamp that is not readable, not JSON, in a schema this build does +/// not understand, or that describes a different directory. +pub(crate) fn read(harness: &Harness, target: &Target) -> Result> { + if harness.predecessor_state_file.is_empty() { + return Ok(None); + } + let path = target.root().join(harness.predecessor_state_file); + if !path.is_file() { + return Ok(None); + } + + let bytes = std::fs::read(&path).map_err(|error| { + Error::refuse( + WireReason::ProviderUnavailable, + format!("{} cannot be read: {error}", path.display()), + ) + })?; + let found: Predecessor = serde_json::from_slice(&bytes).map_err(|error| { + Error::refuse( + WireReason::ProviderUnavailable, + format!( + "{} is not a stamp this build understands: {error}", + path.display() + ), + ) + })?; + + if found.schema_version != PREDECESSOR_SCHEMA { + return Err(Error::refuse( + WireReason::ProviderUnavailable, + format!( + "{} is schema {}, and this build reads {PREDECESSOR_SCHEMA}; adopting a record it \ + cannot read would be claiming files it did not check", + path.display(), + found.schema_version + ), + )); + } + + Ok(Some((path, found))) +} + +impl Predecessor { + /// Check every file the stamp claims against what is on disk. + /// + /// # Errors + /// + /// Propagates a digest failure. + pub(crate) fn account_for(&self, target: &Target) -> Result> { + let mut found = Vec::with_capacity(self.managed_files.len()); + for (relative, expected) in &self.managed_files { + let path = target.root().join(relative); + let claim = if path.is_file() { + let measured = digest::of_file(&path)?; + if measured == format!("{}{expected}", digest::PREFIX) { + Claim::Intact + } else { + Claim::Changed + } + } else { + Claim::Missing + }; + found.push((relative.clone(), claim)); + } + Ok(found) + } + + /// Every claimed path that falls outside what this provider owns. + /// + /// A stamp naming a file this build does not claim is a real conflict: + /// adopting it would record ownership of something no later operation of + /// this provider would ever write, restore or remove. + pub(crate) fn outside(&self, harness: &Harness) -> Vec<&str> { + self.managed_files + .keys() + .map(String::as_str) + .filter(|relative| !harness.owns(relative)) + .collect() + } +} + +/// Move the adopted stamp out of the product's surface, keeping it. +/// +/// # Errors +/// +/// Fails if the control directory cannot be written. +pub(crate) fn keep_aside(control: &Path, stamp: &Path, name: &str) -> Result { + let kept = control.join(KEPT_IN); + std::fs::create_dir_all(&kept).map_err(|error| { + Error::refuse( + WireReason::ProviderUnavailable, + format!("{} cannot be created: {error}", kept.display()), + ) + })?; + let to = kept.join(name); + std::fs::rename(stamp, &to).map_err(|error| { + Error::refuse( + WireReason::ProviderUnavailable, + format!( + "{} could not be moved to {}: {error}", + stamp.display(), + to.display() + ), + ) + })?; + Ok(to) +} diff --git a/crates/harness-runtime/src/facts.rs b/crates/harness-runtime/src/facts.rs index 78020f8..0ea01aa 100644 --- a/crates/harness-runtime/src/facts.rs +++ b/crates/harness-runtime/src/facts.rs @@ -45,6 +45,17 @@ pub struct Harness { pub control_directory: &'static str, /// The provider-owned state file inside a target. pub state_file: &'static str, + /// The state file the frozen estate's program wrote in this same target. + /// + /// Empty when that estate had no module for this product. It is a fact per + /// harness and not derivable: this build's `NDDEV-GROK-PROVIDER.json` had a + /// predecessor called `NDDEV-GROK-BUILD-SETUP.json`, and cursor's and + /// antigravity's differ from their stems too. Deriving the name would have + /// been wrong for three of the seven. + /// + /// Read only by `adopt`, which is a command someone types. Nothing else + /// looks at it, and no automatic path acts on its presence. + pub predecessor_state_file: &'static str, /// The projection profile identity a compiler builds against. pub profile_id: &'static str, /// The top-level entries this provider owns inside a target. @@ -188,6 +199,40 @@ impl Harness { /// implement them — declaring one would let a consumer call an operation /// that cannot be honoured, which is worse than not offering it. /// + /// Whether this build can start the product it installed. + /// + /// Two things have to hold. It must have installed one -- launching a name + /// found on `PATH` starts whatever else shares that spelling, which is not + /// this product and not this build's business. And the product must + /// document an environment variable for its configuration home, because + /// every command in this contract takes a `--target` and a launch that + /// could not point the product at it would be answering a different + /// question than the one asked. + /// + /// Antigravity documents no such variable. It installs and does not launch, + /// and that is the honest pair rather than a launch that ignores its target. + #[must_use] + pub fn can_launch(&self) -> bool { + !self.config_home_env.is_empty() + && matches!( + self.software, + Some(Software { + delivery: Delivery::Artifacts(_), + .. + }) + ) + } + + /// The commands this build answers. + #[must_use] + pub fn commands(&self) -> &'static [Command] { + if self.can_launch() { + Command::ALL + } else { + Command::CORE + } + } + /// The operations this build actually performs. /// /// The software lifecycle is optional in the contract, and declaring an @@ -197,11 +242,21 @@ impl Harness { /// manager this provider does not run. #[must_use] pub fn operations(&self) -> &'static [Operation] { - match self.software { - Some(Software { - delivery: Delivery::Artifacts(_), - .. - }) => Operation::CORE_AND_SOFTWARE, + match (self.can_launch(), self.software) { + ( + true, + Some(Software { + delivery: Delivery::Artifacts(_), + .. + }), + ) => Operation::ALL, + ( + false, + Some(Software { + delivery: Delivery::Artifacts(_), + .. + }), + ) => Operation::CORE_AND_SOFTWARE, _ => Operation::CORE, } } @@ -216,7 +271,7 @@ impl Harness { harness_id: self.harness_id, provider_version: self.version, provider_build_digest: &build_digest, - commands: Command::CORE, + commands: self.commands(), operations: self.operations(), supported_os: &["linux", "macos", "windows"], supported_arch: &["x86_64", "arm64"], @@ -236,6 +291,7 @@ mod tests { /// declaration tests are about. pub(crate) const SAMPLE: Harness = Harness { software: None, + predecessor_state_file: "", harness_id: "sample", provider_id: "sample-setup-system", version: "0.1.0", diff --git a/crates/harness-runtime/src/human.rs b/crates/harness-runtime/src/human.rs index df7879b..90e9517 100644 --- a/crates/harness-runtime/src/human.rs +++ b/crates/harness-runtime/src/human.rs @@ -25,6 +25,7 @@ use setup_core::backup::Pool; use setup_core::stamp::{ProviderState, StateReading}; use setup_core::target::Target; +use crate::adopt; use crate::catalog::{CATALOG_DIRECTORY, Catalog}; use crate::expiry; use crate::facts::{self, Harness}; @@ -77,6 +78,11 @@ pub enum Command { /// The slot to read, or the most recent when absent. backup: Option, }, + /// Take over a target the frozen estate's program still claims. + Adopt { + /// The directory to take over. + target: PathBuf, + }, /// Withdraw everything this provider owns. Remove { /// The directory to clear. @@ -94,7 +100,15 @@ pub enum Command { pub fn is_human_command(name: &str) -> bool { matches!( name, - "list" | "install" | "reinstall" | "select" | "backups" | "restore" | "remove" | "diff" + "list" + | "install" + | "reinstall" + | "select" + | "backups" + | "restore" + | "remove" + | "adopt" + | "diff" ) } @@ -241,6 +255,12 @@ impl Arguments { target: self.target(name)?, }) } + "adopt" => { + self.no_setup(name)?; + Ok(Command::Adopt { + target: self.target(name)?, + }) + } "diff" => { self.no_setup(name)?; Ok(Command::Diff { @@ -271,6 +291,7 @@ pub fn run(harness: &Harness, command: Command) -> Result<()> { } Command::Reinstall { target } => reinstall(harness, &target), Command::Restore { target, backup } => restore(harness, &target, backup), + Command::Adopt { target } => adopt_target(harness, &target), Command::Remove { target } => remove(harness, &target), } } @@ -499,6 +520,96 @@ fn remove(harness: &Harness, target: &Path) -> Result<()> { Ok(()) } +/// Take over a target the frozen estate's program still claims. +/// +/// Everything that could refuse happens before anything is captured or moved: +/// the stamp is read, its schema and its directory are checked, every file it +/// claims is accounted for against what is on disk, and any path it names that +/// this provider does not own is a refusal rather than a silent partial claim. +fn adopt_target(harness: &Harness, target: &Path) -> Result<()> { + let resolved = Target::resolve(target, harness.control_directory)?; + let Some((stamp, found)) = adopt::read(harness, &resolved)? else { + return Err(local(if harness.predecessor_state_file.is_empty() { + format!( + "{} had no module in the frozen estate, so there is no stamp to adopt", + harness.product + ) + } else { + format!( + "{} holds no {}; there is nothing to adopt", + resolved.root().display(), + harness.predecessor_state_file + ) + })); + }; + + let outside = found.outside(harness); + if !outside.is_empty() { + return Err(local(format!( + "{} claims {}, which {} does not own; adopting it would record ownership of files no later operation of this provider would write, restore or remove", + stamp.display(), + outside.join(", "), + harness.provider_id + ))); + } + + if let Some(elsewhere) = found.written_elsewhere(&resolved) { + println!( + "This stamp was written for {elsewhere}, and is being adopted at {}.", + resolved.root().display() + ); + println!(" every path it claims is checked against this target, not that one"); + } + + let accounted = found.account_for(&resolved)?; + println!( + "{} wrote {} for setup {:?}, build {}.", + found.product_name, harness.predecessor_state_file, found.setup_id, found.build_version + ); + for (relative, claim) in &accounted { + println!(" {:-8} {relative}", claim.as_str()); + } + let changed = accounted + .iter() + .filter(|(_, claim)| *claim != adopt::Claim::Intact) + .count(); + if changed > 0 { + println!( + " {changed} of {} are not what the stamp recorded; they are adopted as they are, and the backup below holds them", + accounted.len() + ); + } + + let report = mutate( + harness, + target, + Operation::Install, + Effect::Adopt { + stamp: stamp.clone(), + }, + wire::Applied { + setup_id: Some(found.setup_id.clone()), + ..wire::Applied::default() + }, + )?; + + println!(); + println!( + "{} now owns {}.", + harness.provider_id, + resolved.root().display() + ); + println!( + " previous state captured as {}", + report_field(&report, "backup_ref") + ); + println!( + " the old stamp is kept at {}/adopted/{}", + harness.control_directory, harness.predecessor_state_file + ); + Ok(()) +} + /// Build a plan and apply it through the one write path. fn mutate( harness: &Harness, @@ -597,6 +708,18 @@ fn effect_lines(harness: &Harness, effect: &Effect<'_>, setup_id: Option<&str>) ), ] } + Effect::Adopt { stamp } => vec![ + capture, + format!( + "record {} as the setup this target holds, without writing one file of it", + setup_id.unwrap_or("the setup the old stamp names") + ), + format!( + "move {} into {}/adopted, where the old program no longer sees it", + stamp.display(), + harness.control_directory + ), + ], Effect::Restore { backup_ref } => vec![ capture, match backup_ref { @@ -657,6 +780,8 @@ fn short(digest: &str) -> String { mod tests { #![allow(clippy::unwrap_used, clippy::panic)] + use crate::wire::tests_support::TEST; + use super::*; #[test] @@ -669,6 +794,7 @@ mod tests { "backups", "restore", "remove", + "adopt", "diff", ] { let tokens = if matches!(name, "install" | "select") { @@ -796,6 +922,7 @@ mod tests { "backups", "restore", "remove", + "adopt", "diff", ] { assert!( @@ -1164,4 +1291,441 @@ mod tests { assert_ne!(one, operation_id(&harness, "sha256:def")); assert!(one.starts_with("operation_")); } + + // ── adoption ───────────────────────────────────────────────────────────── + + /// A target holding what the frozen estate's stamp claims. + fn estate_managed(name: &str, files: &[(&str, &str)], claimed: &[(&str, &str)]) -> PathBuf { + let target = std::env::temp_dir().join(format!("adopt-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&target); + fs::create_dir_all(&target).unwrap(); + for (relative, body) in files { + let at = target.join(relative); + if let Some(parent) = at.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(at, body).unwrap(); + } + let managed: serde_json::Map = claimed + .iter() + .map(|(relative, hex)| ((*relative).to_owned(), serde_json::json!(hex))) + .collect(); + fs::write( + target.join(TEST.predecessor_state_file), + serde_json::to_vec_pretty(&serde_json::json!({ + "schema_version": 1, + "product_name": "nddev-test-app", + "build_version": "0.1.0", + "setup_id": "full-auto", + "canonical_target": target.to_string_lossy(), + "managed_files": managed, + })) + .unwrap(), + ) + .unwrap(); + target + } + + fn hex_of(body: &str) -> String { + setup_core::digest::of_bytes(body.as_bytes()) + .trim_start_matches(setup_core::digest::PREFIX) + .to_owned() + } + + #[test] + fn adopt_takes_over_a_target_the_old_program_still_claims() { + let body = "# from the estate\n"; + let target = estate_managed( + "takeover", + &[("AGENTS.md", body)], + &[("AGENTS.md", &hex_of(body))], + ); + + run( + &TEST, + Command::Adopt { + target: target.clone(), + }, + ) + .unwrap(); + + // The setup the old stamp named is now what this provider records. + let resolved = Target::resolve(&target, TEST.control_directory).unwrap(); + let StateReading::Current(state) = + ProviderState::read(resolved.root(), TEST.state_file).unwrap() + else { + panic!("adoption left no state"); + }; + assert_eq!(state.setup_stable_id.as_deref(), Some("full-auto")); + + // The old program looks for its stamp at the top level and no longer + // finds it — but nothing was destroyed. + assert!(!target.join(TEST.predecessor_state_file).exists()); + assert!( + target + .join(TEST.control_directory) + .join("adopted") + .join(TEST.predecessor_state_file) + .is_file() + ); + + // The file the stamp claimed is untouched: adoption changes who owns + // the target, not what is in it. + assert_eq!(fs::read_to_string(target.join("AGENTS.md")).unwrap(), body); + + let _ = fs::remove_dir_all(&target); + } + + #[test] + fn adopt_captures_the_state_it_took_over() { + let body = "# before\n"; + let target = estate_managed( + "captured", + &[("AGENTS.md", body)], + &[("AGENTS.md", &hex_of(body))], + ); + run( + &TEST, + Command::Adopt { + target: target.clone(), + }, + ) + .unwrap(); + + let resolved = Target::resolve(&target, TEST.control_directory).unwrap(); + let slots = resolved.root().join(TEST.control_directory).join("backups"); + let taken = fs::read_dir(&slots).map_or(0, Iterator::count); + assert_eq!(taken, 1, "adoption captured nothing to return to"); + + let _ = fs::remove_dir_all(&target); + } + + #[test] + fn a_stamp_claiming_what_this_provider_does_not_own_is_refused() { + // Recording ownership of a path no later operation would write, + // restore or remove is a claim this build could not keep. + let target = estate_managed( + "outside", + &[("AGENTS.md", "x"), ("somebody-elses.toml", "y")], + &[ + ("AGENTS.md", &hex_of("x")), + ("somebody-elses.toml", &hex_of("y")), + ], + ); + let error = run( + &TEST, + Command::Adopt { + target: target.clone(), + }, + ) + .unwrap_err(); + assert!( + error.detail().contains("somebody-elses.toml"), + "{}", + error.detail() + ); + // Nothing was taken over, and the stamp is where it was. + assert!(target.join(TEST.predecessor_state_file).is_file()); + let _ = fs::remove_dir_all(&target); + } + + #[test] + fn adopt_on_a_target_with_no_stamp_says_there_is_nothing_to_adopt() { + let target = std::env::temp_dir().join(format!("adopt-none-{}", std::process::id())); + let _ = fs::remove_dir_all(&target); + fs::create_dir_all(&target).unwrap(); + let error = run( + &TEST, + Command::Adopt { + target: target.clone(), + }, + ) + .unwrap_err(); + assert!( + error.detail().contains("nothing to adopt"), + "{}", + error.detail() + ); + let _ = fs::remove_dir_all(&target); + } + + #[test] + fn a_harness_with_no_predecessor_says_so_rather_than_looking_for_a_file() { + let mut fresh = TEST; + fresh.predecessor_state_file = ""; + let target = std::env::temp_dir().join(format!("adopt-fresh-{}", std::process::id())); + let _ = fs::remove_dir_all(&target); + fs::create_dir_all(&target).unwrap(); + let error = run( + &fresh, + Command::Adopt { + target: target.clone(), + }, + ) + .unwrap_err(); + assert!( + error.detail().contains("no module in the frozen estate"), + "{}", + error.detail() + ); + let _ = fs::remove_dir_all(&target); + } + + #[test] + fn a_file_that_drifted_since_the_stamp_is_accounted_for_not_assumed() { + // The estate's own digest is the only record of what it wrote. A file + // that no longer matches is adopted as it is, and the backup holds it — + // but the difference is stated rather than passed over. + let target = estate_managed( + "drifted", + &[("AGENTS.md", "what is there now")], + &[("AGENTS.md", &hex_of("what the estate wrote"))], + ); + let resolved = Target::resolve(&target, TEST.control_directory).unwrap(); + let (_, found) = adopt::read(&TEST, &resolved).unwrap().unwrap(); + let accounted = found.account_for(&resolved).unwrap(); + assert_eq!( + accounted, + vec![("AGENTS.md".to_owned(), adopt::Claim::Changed)] + ); + + // And adoption still succeeds: it is a takeover, not a verification. + run( + &TEST, + Command::Adopt { + target: target.clone(), + }, + ) + .unwrap(); + assert_eq!( + fs::read_to_string(target.join("AGENTS.md")).unwrap(), + "what is there now" + ); + let _ = fs::remove_dir_all(&target); + } + + // ── a populated home, through the whole lifecycle ──────────────────────── + + /// Fingerprint a tree using nothing this program owns. + /// + /// `setup_core::digest::of_tree` is what the provider itself uses to decide + /// a target is unchanged, so comparing against it would be asking the same + /// function twice and believing the answer. This walks with `std::fs` and + /// hashes with `sha2` directly. A restore that returned *almost* the right + /// tree — a mode dropped, an empty directory lost, a byte reordered — is + /// caught here and would not be caught by the other. + /// + /// The provider's own bookkeeping is skipped: the control directory and the + /// state file are this build's, not the target's, and they are supposed to + /// appear. + fn independent_fingerprint(root: &Path, harness: &Harness) -> String { + fn walk(at: &Path, base: &Path, skip: &[&str], into: &mut Vec) { + let mut entries: Vec<_> = fs::read_dir(at) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + entries.sort(); + for path in entries { + let relative = path + .strip_prefix(base) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if skip.iter().any(|name| relative == *name) { + continue; + } + let found = fs::symlink_metadata(&path).unwrap(); + if found.is_dir() { + into.push(format!("d {relative}")); + walk(&path, base, skip, into); + } else if found.is_symlink() { + let to = fs::read_link(&path).unwrap(); + into.push(format!("l {relative} -> {}", to.to_string_lossy())); + } else { + let bytes = fs::read(&path).unwrap(); + let readonly = found.permissions().readonly(); + into.push(format!( + "f {relative} {} {} ro={readonly}", + bytes.len(), + setup_core::digest::of_bytes(&bytes) + )); + } + } + } + let skip = [harness.control_directory, harness.state_file]; + let mut lines = Vec::new(); + walk(root, root, &skip, &mut lines); + setup_core::digest::of_bytes(lines.join("\n").as_bytes()) + } + + /// A target with the awkward content a real configuration home grows. + fn populated(name: &str) -> (PathBuf, PathBuf) { + let base = scratch(name); + let catalog = base.join("setups"); + fs::create_dir_all(&catalog).unwrap(); + write_setup( + &catalog, + "baseline", + &[("AGENTS.md", "# baseline\n"), ("settings.json", "{}")], + ); + let target = base.join("target"); + + // Inside what this provider owns. + let deep = target.join("skills/one/two/three/four/five/six"); + fs::create_dir_all(&deep).unwrap(); + fs::write(deep.join("leaf.md"), "a long way down\n").unwrap(); + fs::write(target.join("skills/пример.md"), "имя не в ASCII\n").unwrap(); + fs::write(target.join("skills/empty.md"), b"").unwrap(); + // CRLF on purpose: a restore that normalized line endings would be + // returning a different file and reporting success. + fs::write(target.join("skills/crlf.md"), b"one\r\ntwo\r\n").unwrap(); + fs::write(target.join("skills/big.md"), vec![b'x'; 300_000]).unwrap(); + fs::create_dir_all(target.join("skills/an-empty-directory")).unwrap(); + fs::write(target.join("AGENTS.md"), "# what was here first\n").unwrap(); + + // Beside it, and none of this provider's business. + fs::write(target.join("unrelated.txt"), "mine").unwrap(); + fs::write(target.join(".credentials.json"), "SECRET").unwrap(); + fs::create_dir_all(target.join("sessions/2026")).unwrap(); + fs::write(target.join("sessions/2026/log.jsonl"), "{}\n").unwrap(); + + (catalog, target) + } + + #[test] + fn a_populated_target_comes_back_byte_for_byte_after_a_restore() { + let (catalog, target) = populated("populated-restore"); + let harness = harness(); + let before = independent_fingerprint(&target, &harness); + + install(&catalog, &target, "baseline", Operation::Install); + + // What the setup declares is now what is there, and the deep tree that + // was in the same namespace is gone with it — that is what owning a + // namespace means. + assert_eq!( + fs::read_to_string(target.join("AGENTS.md")).unwrap(), + "# baseline\n" + ); + assert!(!target.join("skills/one").exists()); + + // Nothing outside those namespaces moved. + assert_eq!( + fs::read_to_string(target.join("unrelated.txt")).unwrap(), + "mine" + ); + assert_eq!( + fs::read_to_string(target.join(".credentials.json")).unwrap(), + "SECRET" + ); + assert!(target.join("sessions/2026/log.jsonl").is_file()); + + run( + &harness, + Command::Restore { + target: target.clone(), + backup: None, + }, + ) + .unwrap(); + + assert_eq!( + independent_fingerprint(&target, &harness), + before, + "the restored target is not the one that was captured" + ); + + let _ = fs::remove_dir_all(target.parent().unwrap()); + } + + #[test] + fn a_backup_never_holds_what_it_could_not_put_back() { + // The slot is what a restore replays. A credential swept into one would + // be a secret this program copied without being asked, and a restore + // would then write it back over whatever the product had since stored. + let (catalog, target) = populated("populated-slot"); + install(&catalog, &target, "baseline", Operation::Install); + + let slots = target.join(harness().control_directory).join("backups"); + let mut swept = Vec::new(); + for slot in fs::read_dir(&slots).unwrap() { + let slot = slot.unwrap().path(); + if slot.is_dir() { + for found in walkdir(&slot) { + let name = found.to_string_lossy().into_owned(); + if name.contains("credentials") || name.contains("sessions") { + swept.push(name); + } + } + } + } + assert!(swept.is_empty(), "a backup slot holds {swept:?}"); + + let _ = fs::remove_dir_all(target.parent().unwrap()); + } + + /// Every path under a root, for a test that needs to look at all of them. + fn walkdir(root: &Path) -> Vec { + let mut found = Vec::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(at) = pending.pop() { + let Ok(entries) = fs::read_dir(&at) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + pending.push(path.clone()); + } + found.push(path); + } + } + found + } + + #[test] + fn remove_takes_a_populated_namespace_and_leaves_the_rest() { + let (catalog, target) = populated("populated-remove"); + let harness = harness(); + install(&catalog, &target, "baseline", Operation::Install); + + run( + &harness, + Command::Remove { + target: target.clone(), + }, + ) + .unwrap(); + + for owned in ["AGENTS.md", "settings.json", "skills"] { + assert!( + !target.join(owned).exists(), + "{owned} survived a remove that claims to own it" + ); + } + assert_eq!( + fs::read_to_string(target.join("unrelated.txt")).unwrap(), + "mine" + ); + assert!(target.join("sessions/2026/log.jsonl").is_file()); + + let _ = fs::remove_dir_all(target.parent().unwrap()); + } + + #[test] + #[cfg(unix)] + fn a_read_only_file_in_an_owned_namespace_is_still_replaced() { + // A product, or a person, can leave a file unwritable. The namespace is + // still this provider's to replace, and failing there would leave a + // half-applied target behind a permission bit. + use std::os::unix::fs::PermissionsExt; + let (catalog, target) = populated("populated-readonly"); + let stubborn = target.join("skills/пример.md"); + fs::set_permissions(&stubborn, fs::Permissions::from_mode(0o444)).unwrap(); + + install(&catalog, &target, "baseline", Operation::Install); + assert!(!stubborn.exists(), "a read-only file blocked the install"); + + let _ = fs::remove_dir_all(target.parent().unwrap()); + } } diff --git a/crates/harness-runtime/src/lib.rs b/crates/harness-runtime/src/lib.rs index 3ef6dda..e5991ff 100644 --- a/crates/harness-runtime/src/lib.rs +++ b/crates/harness-runtime/src/lib.rs @@ -25,6 +25,7 @@ //! perform would let a consumer call something that cannot be honoured, which //! is worse than not offering it. +pub(crate) mod adopt; pub mod catalog; pub mod expiry; pub mod facts; @@ -146,8 +147,28 @@ fn print_help(harness: &Harness) { println!(" plan-operation --target --json --operation ..."); println!(" apply-operation --target --json --plan --plan-digest ..."); println!(" recover-operation --target --json"); + if harness.can_launch() { + println!(" launch --target --prefix --json [-- ]"); + } println!(); - println!(); + if harness + .operations() + .contains(&provider_v3::Operation::SoftwareInstall) + { + println!( + "This build also installs {} itself. Those operations take a", + harness.product + ); + println!("`--prefix` for the program, distinct from the `--target` that holds"); + println!("its configuration, and the bytes are fetched between planning and"); + println!("applying by whoever holds the network:"); + println!(); + println!( + " plan-operation --operation software_install --target --prefix ..." + ); + println!(" apply-operation --prefix --software-artifact ..."); + println!(); + } println!("Your commands:"); println!(" list"); println!(" status --target "); @@ -158,7 +179,19 @@ fn print_help(harness: &Harness) { println!(" backups --target "); println!(" restore [--backup ] --target "); println!(" remove --target "); + if !harness.predecessor_state_file.is_empty() { + println!(" adopt --target "); + } println!(); + if !harness.predecessor_state_file.is_empty() { + println!( + "`adopt` takes over a target still carrying {},", + harness.predecessor_state_file + ); + println!("written by the estate that came before this one. It is a command you"); + println!("type, never something install does behind you, and it deletes nothing."); + println!(); + } println!("Every one takes an explicit --target. There is no default: a change"); println!("aimed at a guessed path is a change aimed at someone else's state."); println!(); diff --git a/crates/harness-runtime/src/software.rs b/crates/harness-runtime/src/software.rs index 4d6f71a..e84fee2 100644 --- a/crates/harness-runtime/src/software.rs +++ b/crates/harness-runtime/src/software.rs @@ -242,3 +242,126 @@ pub(crate) fn apply( "files": installed.files, })) } + +/// Start the exact program a software install placed, replacing this process. +/// +/// Not a name looked up on `PATH`: that starts whatever else shares the +/// spelling, which is the failure this command exists to avoid. The path comes +/// from the same table the plan was built from, and it must resolve to a +/// regular file this host can execute — an existing but non-executable file is +/// a refusal with a reason, not a process error surfacing from somewhere else. +/// +/// On success this does not return. The caller's stdio and exit status become +/// the product's, which is what starting a program means; everything that could +/// refuse has already refused by then. +/// +/// # Errors +/// +/// Refuses when this build does not declare `launch`, when no `--prefix` was +/// given, when nothing is installed there, or when what is there cannot be run. +pub(crate) fn launch( + harness: &Harness, + target: &Path, + prefix: Option<&Path>, + arguments: &[String], +) -> Result { + if !harness.can_launch() { + return Err(Error::refuse( + WireReason::UnsupportedOperation, + format!( + "{} does not declare launch: {}", + harness.provider_id, + if harness.config_home_env.is_empty() { + format!( + "{} documents no environment variable for its configuration home, so a \ + launch could not point it at the target this command was given", + harness.product + ) + } else { + "this build installs no software, and launching a name found on PATH would \ + start whatever else shares it" + .to_owned() + }, + ), + )); + } + + let declared = declared(harness)?; + let root = program_directory(prefix, Operation::Launch)?; + let executable = root.join("bin").join(declared.command); + + let found = executable.metadata().map_err(|error| { + Error::refuse( + WireReason::ProviderUnavailable, + format!( + "{} is not installed under {}: {error}; run software_install first", + declared.command, + root.display() + ), + ) + })?; + if !found.is_file() { + return Err(Error::refuse( + WireReason::ProviderUnavailable, + format!("{} is not a regular file", executable.display()), + )); + } + if !is_executable(&found) { + return Err(Error::refuse( + WireReason::ProviderUnavailable, + format!( + "{} exists but this host cannot execute it", + executable.display() + ), + )); + } + + let mut command = std::process::Command::new(&executable); + command.args(arguments); + // The target is what this provider configured, and the product's own + // documented variable is how it is told. Nothing else in the environment is + // touched: filtering another program's environment would be deciding what + // it needs, and only its vendor knows that. + command.env(harness.config_home_env, target); + + Err(replace_this_process(command, &executable)) +} + +/// Whether the mode bits say this host can run it. +#[cfg(unix)] +fn is_executable(found: &std::fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + found.mode() & 0o111 != 0 +} + +/// Windows decides by extension, not by a mode bit there is none of. +#[cfg(not(unix))] +fn is_executable(_found: &std::fs::Metadata) -> bool { + true +} + +/// Hand this process to the product, and only return if that failed. +#[cfg(unix)] +fn replace_this_process(mut command: std::process::Command, executable: &Path) -> Error { + use std::os::unix::process::CommandExt; + // `exec` returns only on failure. The product inherits this process, so its + // stdio and its exit status are the ones the caller sees, with nothing of + // this program's left in between. + let failure = command.exec(); + Error::refuse( + WireReason::ProviderUnavailable, + format!("{} could not be started: {failure}", executable.display()), + ) +} + +/// Windows has no `exec`, so the status is carried back by hand. +#[cfg(not(unix))] +fn replace_this_process(mut command: std::process::Command, executable: &Path) -> Error { + match command.status() { + Ok(status) => std::process::exit(status.code().unwrap_or(1)), + Err(failure) => Error::refuse( + WireReason::ProviderUnavailable, + format!("{} could not be started: {failure}", executable.display()), + ), + } +} diff --git a/crates/harness-runtime/src/wire.rs b/crates/harness-runtime/src/wire.rs index 2809057..070f10d 100644 --- a/crates/harness-runtime/src/wire.rs +++ b/crates/harness-runtime/src/wire.rs @@ -70,10 +70,11 @@ pub fn dispatch(harness: &Harness, invocation: Invocation) -> Result recover(harness, &target), - Invocation::Launch { .. } => Err(Error::refuse( - WireReason::UnsupportedOperation, - "this provider owns the configuration only and does not start the product", - )), + Invocation::Launch { + target, + prefix, + arguments, + } => software::launch(harness, &target, prefix.as_deref(), &arguments), } } @@ -516,6 +517,17 @@ pub(crate) enum Effect<'a> { /// The setup to write. setup: &'a Setup, }, + /// Take over a target the frozen estate's program still claims. + /// + /// Writes none of the product's files: they are already what the old stamp + /// recorded, and this only changes who owns them. The stamp itself is moved + /// into this provider's control directory rather than deleted — the old + /// program stops recognising it there, and the pre-adoption state is one + /// `mv` away from being back. + Adopt { + /// The stamp file to move aside. + stamp: std::path::PathBuf, + }, /// Write a verified `HarnessBundle` over those namespaces. /// /// The bundle is read and checked *before* this effect exists, so reaching @@ -759,6 +771,9 @@ pub(crate) fn perform( replace_managed_from(harness, &resolved, &setup.payload) } Effect::MaterializeBundle { files } => write_bundle_files(harness, &resolved, files), + Effect::Adopt { stamp } => { + crate::adopt::keep_aside(&control, stamp, harness.predecessor_state_file).map(|_| ()) + } }; // On failure the journal stays in `prepared`, which is what makes the @@ -1091,6 +1106,7 @@ pub(crate) mod tests_support { pub(crate) const TEST: Harness = Harness { software: Some(TEST_SOFTWARE), + predecessor_state_file: "NDDEV-TEST-SETUP.json", harness_id: "test", provider_id: "test-setup-system", version: "0.1.0", @@ -1999,12 +2015,96 @@ mod tests { } #[test] - fn launch_is_refused_because_this_runtime_does_not_start_a_product() { - let target = seeded("launch"); + fn launch_with_nothing_installed_says_to_install_first() { + // The failure this command exists to avoid is starting a name found on + // PATH. So an empty prefix is a refusal that names the path it looked + // at, not a fallback to whatever else answers to `test-harness`. + let target = seeded("launch-empty"); + let prefix = ready_prefix(&target); + let error = refuse(args("launch", &target, &["--prefix", &prefix])); + assert_eq!(error.reason(), Some(WireReason::ProviderUnavailable)); + assert!( + error.detail().contains("software_install"), + "{}", + error.detail() + ); + } + + #[test] + fn launch_without_a_prefix_says_where_a_program_lives() { + let target = seeded("launch-noprefix"); + let error = refuse(args("launch", &target, &[])); + assert!(error.detail().contains("--prefix"), "{}", error.detail()); + } + + #[test] + fn a_build_that_cannot_point_the_product_at_a_target_does_not_declare_launch() { + // Every command here takes a `--target`. A product documenting no + // environment variable for its configuration home cannot be pointed at + // one, so a launch would be answering a different question. + let mut mute = TEST; + mute.config_home_env = ""; + assert!(!mute.can_launch()); + let info = mute.provider_info().unwrap(); + assert!(!info.declares(Operation::Launch)); + + let error = software::launch(&mute, Path::new("/nowhere"), None, &[]).unwrap_err(); + assert_eq!(error.reason(), Some(WireReason::UnsupportedOperation)); + assert!( + error.detail().contains("configuration home"), + "{}", + error.detail() + ); + } + + #[test] + fn a_build_that_installs_nothing_does_not_declare_launch_either() { + let mut bare = TEST; + bare.software = None; + assert!(!bare.can_launch()); + assert!(!bare.provider_info().unwrap().declares(Operation::Launch)); + let error = software::launch(&bare, Path::new("/nowhere"), None, &[]).unwrap_err(); + assert!(error.detail().contains("PATH"), "{}", error.detail()); + } + + #[test] + fn a_build_that_installs_and_can_be_pointed_declares_launch() { + assert!(TEST.can_launch()); + let info = TEST.provider_info().unwrap(); + assert!(info.declares(Operation::Launch)); + assert!(info.supported_commands.iter().any(|c| c == "launch")); + } + + #[test] + fn what_launch_starts_is_the_file_that_was_installed() { + // Proven without replacing this process: install, then check that the + // path launch resolves is the exact executable the install exposed, + // and that it runs and reports the version the plan named. + let target = seeded("launch-installed"); + let file = downloaded(&target, TEST_PAYLOAD); + let applied = plan_then_install(&target, "software_install", Some(&file)); + let exposed = std::path::PathBuf::from(applied["executable"].as_str().unwrap()); + + let prefix = ready_prefix(&target); assert_eq!( - refuse(args("launch", &target, &[])).reason(), - Some(WireReason::UnsupportedOperation) + exposed, + std::path::Path::new(&prefix) + .join("bin") + .join("test-harness") ); + assert!(exposed.symlink_metadata().is_ok()); + + #[cfg(unix)] + { + let output = std::process::Command::new(&exposed) + .env(TEST.config_home_env, &target) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "test-harness 1.2.3" + ); + } } #[test] diff --git a/crates/opencode-setup-system/src/main.rs b/crates/opencode-setup-system/src/main.rs index d440e6d..578751f 100644 --- a/crates/opencode-setup-system/src/main.rs +++ b/crates/opencode-setup-system/src/main.rs @@ -31,6 +31,7 @@ pub const OPENCODE: Harness = Harness { config_home_env: "OPENCODE_CONFIG_DIR", control_directory: ".opencode-setup-system", state_file: "NDDEV-OPENCODE-PROVIDER.json", + predecessor_state_file: "NDDEV-OPENCODE-SETUP.json", profile_id: "opencode/native-files/1", // Everything outside this list is a sibling overlay preserved verbatim. // `opencode.json` carries both the settings and the MCP entries: the product diff --git a/crates/provider-v3/src/argv.rs b/crates/provider-v3/src/argv.rs index e53224f..abfe379 100644 --- a/crates/provider-v3/src/argv.rs +++ b/crates/provider-v3/src/argv.rs @@ -106,7 +106,16 @@ pub enum Invocation { /// Start the product. Optional command. Launch { /// The target the caller named. + /// + /// Becomes the product's configuration home, through the environment + /// variable the product documents for it. A product that documents none + /// cannot honour a target, which is why this command is not declared + /// there. target: PathBuf, + /// The program directory holding what a software install placed. + prefix: Option, + /// Everything after a bare `--`, handed to the product verbatim. + arguments: Vec, }, } @@ -162,7 +171,7 @@ impl Invocation { | Self::ApplyOperation { target, .. } | Self::RecoverOperation { target } | Self::Status { target } - | Self::Launch { target } => Some(target), + | Self::Launch { target, .. } => Some(target), } } @@ -211,7 +220,12 @@ where return Ok(Invocation::ProviderInfo); } - let mut flags = Flags::parse(rest)?; + // Everything after a bare `--` belongs to the product `launch` starts, so + // it is taken off before this parser sees it. No other command has anything + // to pass on, and one that finds a `--` gets an empty tail and refuses the + // leftovers the same way it always would. + let (mine, passthrough) = Flags::split_passthrough(rest); + let mut flags = Flags::parse(&mine)?; let target = PathBuf::from(flags.take_required("--target")?); if !flags.take_switch("--json") { return Err(local(format!("{command} requires --json"))); @@ -221,7 +235,11 @@ where Command::ProviderInfo => return Err(local("provider-info never reaches this branch")), Command::Status => Invocation::Status { target }, Command::RecoverOperation => Invocation::RecoverOperation { target }, - Command::Launch => Invocation::Launch { target }, + Command::Launch => Invocation::Launch { + target, + prefix: flags.take_prefix()?, + arguments: passthrough, + }, Command::ValidateBundle => { let Some(bundle) = flags.take_bundle()? else { return Err(local("validate-bundle requires a bundle")); @@ -293,6 +311,19 @@ struct Flags { const REPEATABLE: &[&str] = &["--software-artifact"]; impl Flags { + /// Split a bare `--` off the end, keeping what follows verbatim. + /// + /// Only `launch` has anything to pass on, and what it passes belongs to + /// another program: `-p`, `--help` and `--version` all mean something to the + /// product and nothing here. A separator is the one way to say "stop + /// reading these as mine" without guessing which of them are. + fn split_passthrough(tokens: &[String]) -> (Vec, Vec) { + match tokens.iter().position(|token| token == "--") { + Some(at) => (tokens[..at].to_vec(), tokens[at + 1..].to_vec()), + None => (tokens.to_vec(), Vec::new()), + } + } + fn parse(tokens: &[String]) -> Result { let mut values: BTreeMap> = BTreeMap::new(); let mut switches = Vec::new();