From 9fc3fb263574f4b35d37c4f3b3c98757db4eca24 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Tue, 25 Aug 2026 23:23:15 +0500 Subject: [PATCH] feat: launch the installed product, and adopt a target the old estate still claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two capabilities, both of which needed the software lifecycle to exist first. `launch` starts the exact executable a software install placed under `--prefix`, never a name found on `PATH` — that starts whatever else shares the spelling. It points the product at `--target` through the environment variable the product's own documentation names, passes anything after a bare `--` through verbatim, and replaces this process so the caller's stdio and exit status become the product's. Declared where both halves hold: the build installs software, and the product documents a configuration-home variable to be pointed with. `adopt` takes over a target still carrying the stamp file written by the Python estate these seven replaced. It is a command someone types, never something install does behind them. Nothing is deleted: the stamp moves into this provider's control directory, where the old program stops recognising it and the pre-adoption state is one `mv` away from being back, on top of the backup the adoption captures first. Every file the stamp claims is accounted for as intact, changed or missing before anything is taken over, and a claimed path this provider does not own is a refusal rather than a claim it could not keep. Alongside them, four tests that run a populated configuration home through install, backup, restore and remove — a tree six levels deep, a name outside ASCII, an empty directory, CRLF bytes, a read-only file — and compare against a fingerprint computed with `std::fs` and `sha2` directly, rather than against the digest the provider itself uses to decide a target is unchanged. `--help` now describes what each build actually does rather than what they all once did. --- README.md | 10 +- SUPPORT.md | 10 +- crates/harness-runtime/src/adopt.rs | 204 ++++++++ crates/harness-runtime/src/facts.rs | 68 ++- crates/harness-runtime/src/human.rs | 566 ++++++++++++++++++++++- crates/harness-runtime/src/lib.rs | 35 +- crates/harness-runtime/src/software.rs | 123 +++++ crates/harness-runtime/src/wire.rs | 116 ++++- crates/opencode-setup-system/src/main.rs | 1 + crates/provider-v3/src/argv.rs | 37 +- 10 files changed, 1145 insertions(+), 25 deletions(-) create mode 100644 crates/harness-runtime/src/adopt.rs 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();