From 279059dadf3902a18578a7cf203637cf6bf58c00 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Thu, 27 Aug 2026 04:21:57 +0500 Subject: [PATCH 1/2] chore(release): 0.0.6 Two defects a consumer found on a machine nobody here has, one this build could not see from Linux, and the last product that was an exception. provider_plan_digest was null after every operation of every kind -- read out of the plan object rather than the envelope that carries the digest taken over it. It survived four releases because status did not publish the field, and a consumer skips what is absent. On Windows nothing was ever exposed, so no software_update was ever an update. A backup refused a link while copying rather than before. A backup can be held now, so a long series of captures cannot evict the baseline it returns to. Pi installs from one tarball like the other six, and all seven declare the same four optional operations. Rendered from NDDev-it-com/setup-systems@cb1870d. Claude-Session: https://claude.ai/code/session_012UZkeU7yQhyZqKAaDaQEjA --- .github/workflows/ci.yml | 12 ++ .github/workflows/security.yml | 3 + CHANGELOG.md | 40 ++++ Cargo.lock | 8 +- Cargo.toml | 8 +- README.md | 14 +- crates/harness-runtime/src/human.rs | 157 +++++++++++++- crates/harness-runtime/src/lib.rs | 88 +++++++- crates/harness-runtime/src/software.rs | 26 ++- crates/harness-runtime/src/wire.rs | 235 +++++++++++++++++++++ crates/setup-core/src/backup.rs | 271 ++++++++++++++++++++++++- crates/setup-core/src/software.rs | 178 +++++++++++++++- install.ps1 | 2 +- install.sh | 2 +- references/opencode-baseline.json | 4 +- 15 files changed, 1009 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a1c92d..8de11dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,18 @@ on: branches: [main] push: branches: [main] + # There was no way to ask this repository for a run, and on 2026-08-26 that + # mattered: GitHub dropped push events for about two hours, and six of the + # seven trees were left with no completed `ci` on the `main` they had just + # merged. The code was proven -- the same contexts were green on the pull + # request minutes earlier -- but nothing on `main` said so, and the only way + # to ask again was to push a commit that changed nothing. + # + # A repository whose proof can only be requested by changing it is a + # repository that invites empty commits. The monorepo has carried this + # trigger since its gate was built; the rendered trees did not, for no reason + # anyone chose. + workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 18d83fc..919d0db 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -8,6 +8,9 @@ on: schedule: # Weekly, so an advisory published after the last push is still seen. - cron: '17 4 * * 1' + # Same reason as `ci`: an advisory worth re-checking now, or a scheduled run + # that never started, should not require a commit to ask about. + workflow_dispatch: permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index cb0afde..7b711ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,46 @@ including claims a later release made false. ## [Unreleased] +## [0.0.6] - 2026-08-27 + +Two defects a consumer found, one this build could not see, and the +last product that was an exception. + +- **`provider_plan_digest` was null after every operation of every kind.** It + was read out of the plan *object*, which never carries it: the digest is + taken over the plan and travels beside it in the planner's envelope. Reported + as an empty-setup defect and never about emptiness at all. It stayed + invisible for four releases because `status` did not publish the field, and a + consumer skips what is absent -- publishing what is persisted is what made it + a value that could be compared and refused. +- **A backup refused a link while copying rather than before.** The slot was + created, files were written into it, and the walk then stopped -- a partial + operation and control artifacts for a shape that was knowable for free. Owned + paths are read before planning and again before any capture now, every + unsupported entry is named at once, and nothing is followed. +- **On Windows nothing was ever exposed, so no update was ever an update.** + Reading which version a prefix exposes resolved the command's path, and + Windows writes a hard link or a copy there rather than a link -- so the answer + was always "nothing is exposed" and every `software_update` refused as an + update of nothing. Shipped in 0.0.4 and 0.0.5. The version is recorded beside + the command and read back now; a dangling link still exposes nothing. +- **Pi installs like the other six.** It declared no software lifecycle on the + stated ground that npm resolves its closure at install time. The vendor ships + `npm-shrinkwrap.json`, so the closure is fixed -- and it does not matter, + because the published bundle imports only Node built-ins and runs with no + `node_modules` at all. All seven now declare the same four optional + operations. Its entry point is JavaScript, so Windows exposes `pi.cmd` + rather than a copy no platform would run. +- **A backup can be held.** The pool rolls at ten slots, so a long series of + captures evicted the baseline it meant to return to. A held slot is not + reclaimed and is not counted against the bound, the reason is recorded beside + it so a full pool says who would lose what, and the last reclaimable slot + cannot be held -- a target that can never be backed up again is worse than + the eviction. + +Vendor versions advanced where they moved: claude 2.1.246, cursor +2026.08.25-3e8eec8, antigravity 1.1.21. + ## [0.0.5] - 2026-08-26 The catalog now travels with the program, and a target's identity is diff --git a/Cargo.lock b/Cargo.lock index 488888b..523c0a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,7 +66,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "harness-runtime" -version = "0.0.5" +version = "0.0.6" dependencies = [ "provider-v3", "serde", @@ -128,7 +128,7 @@ dependencies = [ [[package]] name = "opencode-setup-system" -version = "0.0.5" +version = "0.0.6" dependencies = [ "harness-runtime", "provider-v3", @@ -147,7 +147,7 @@ dependencies = [ [[package]] name = "provider-v3" -version = "0.0.5" +version = "0.0.6" dependencies = [ "serde", "serde_json", @@ -209,7 +209,7 @@ dependencies = [ [[package]] name = "setup-core" -version = "0.0.5" +version = "0.0.6" dependencies = [ "miniz_oxide", "serde", diff --git a/Cargo.toml b/Cargo.toml index 5dfc989..5f8612c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.0.5" +version = "0.0.6" edition = "2024" rust-version = "1.89" license = "AGPL-3.0-or-later" @@ -23,9 +23,9 @@ sha2 = "0.11" # `setup-core::archive`); an inflate loop is not, because its bugs are # memory-safety bugs and it is not improved by being hand-written here. miniz_oxide = "0.9" -setup-core = { path = "crates/setup-core", version = "0.0.5" } -provider-v3 = { path = "crates/provider-v3", version = "0.0.5" } -harness-runtime = { path = "crates/harness-runtime", version = "0.0.5" } +setup-core = { path = "crates/setup-core", version = "0.0.6" } +provider-v3 = { path = "crates/provider-v3", version = "0.0.6" } +harness-runtime = { path = "crates/harness-runtime", version = "0.0.6" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 539486e..b312270 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,18 @@ release's own `SHA256SUMS`, and places it at a predictable path: `~/.local/bin` on Linux and macOS, `%LOCALAPPDATA%\Programs` on Windows. Neither needs privilege and neither registers anything anywhere. +Somewhere else instead: + +```bash +OPENCODE_INSTALL_DIR=/opt/opencode-setup-system sh install.sh +``` + +The same variable on both scripts, and it is `OPENCODE_INSTALL_DIR` +rather than the longer prefix the setup-catalog variable uses -- the installer +is named after the product, not after the crate. It was always accepted and +never written down here, which is how someone reading only this page installs +into a home directory they did not mean to write to. + Releases carry six binaries — Linux, macOS and Windows, on x86_64 and arm64 — which is what `provider-info` declares, so the declaration and what you can download say the same thing. @@ -151,7 +163,7 @@ release is a convenience, not the authorised copy. ```bash docker run --rm -v "$HOME/.config:/config" \ - ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.5 \ + ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.6 \ status --target /config/ --json ``` diff --git a/crates/harness-runtime/src/human.rs b/crates/harness-runtime/src/human.rs index 8f4a1e7..66a68f3 100644 --- a/crates/harness-runtime/src/human.rs +++ b/crates/harness-runtime/src/human.rs @@ -54,6 +54,22 @@ pub enum Command { /// The setup to apply. setup: String, }, + /// Keep one backup until it is released, so retention cannot reclaim it. + Hold { + /// The target whose pool holds it. + target: PathBuf, + /// The slot to keep. Named, never inferred. + backup: Option, + /// Why it is held, so whoever meets a full pool knows the cost. + reason: Option, + }, + /// Let retention have a held backup back. + Release { + /// The target whose pool holds it. + target: PathBuf, + /// The slot to let go. + backup: Option, + }, /// Report which versions of the product are installed, and which is exposed. Software { /// The program directory to read. @@ -123,6 +139,8 @@ pub fn is_human_command(name: &str) -> bool { | "diff" | "software" | "rollback" + | "hold" + | "release" ) } @@ -151,6 +169,7 @@ struct Arguments { backup: Option, prefix: Option, to: Option, + reason: Option, positional: Vec, } @@ -162,13 +181,14 @@ impl Arguments { backup: None, prefix: None, to: None, + reason: None, positional: Vec::new(), }; let mut index = 0; while index < rest.len() { let Some(token) = rest.get(index) else { break }; match token.as_str() { - "--target" | "--backup" | "--prefix" | "--to" => { + "--target" | "--backup" | "--prefix" | "--to" | "--reason" => { let Some(value) = rest.get(index + 1) else { return Err(local(format!("{token} has no value"))); }; @@ -194,6 +214,12 @@ impl Arguments { } parsed.to = Some(value.clone()); } + "--reason" => { + if parsed.reason.is_some() { + return Err(local("--reason was given twice")); + } + parsed.reason = Some(value.clone()); + } _ => { if parsed.backup.is_some() { return Err(local("--backup was given twice")); @@ -246,7 +272,7 @@ impl Arguments { } fn into_command(self, name: &str) -> Result { - if self.backup.is_some() && name != "restore" { + if self.backup.is_some() && !matches!(name, "restore" | "hold" | "release") { return Err(local(format!("--backup is not an argument of {name}"))); } if self.prefix.is_some() && !matches!(name, "software" | "rollback") { @@ -255,11 +281,29 @@ impl Arguments { if self.to.is_some() && name != "rollback" { return Err(local(format!("--to is not an argument of {name}"))); } + if self.reason.is_some() && name != "hold" { + return Err(local(format!("--reason is not an argument of {name}"))); + } match name { "list" => { self.no_setup(name)?; Ok(Command::List) } + "hold" => { + self.no_setup(name)?; + Ok(Command::Hold { + target: self.target(name)?, + backup: self.backup, + reason: self.reason, + }) + } + "release" => { + self.no_setup(name)?; + Ok(Command::Release { + target: self.target(name)?, + backup: self.backup, + }) + } "software" => { self.no_setup(name)?; Ok(Command::Software { @@ -351,11 +395,106 @@ pub fn run(harness: &Harness, command: Command) -> Result<()> { Command::Restore { target, backup } => restore(harness, &target, backup), Command::Adopt { target } => adopt_target(harness, &target), Command::Remove { target } => remove(harness, &target), + Command::Hold { + target, + backup, + reason, + } => hold(harness, &target, backup.as_deref(), reason.as_deref()), + Command::Release { target, backup } => release(harness, &target, backup.as_deref()), Command::Software { prefix } => software(harness, &prefix), Command::Rollback { prefix, to } => rollback(harness, &prefix, to.as_deref()), } } +/// Keep one backup until it is released. +/// +/// The pool rolls: ten slots, oldest evicted. A long series of captures makes +/// more than that, so a baseline someone means to return to at the end is gone +/// by the time they get there. A hold is the smallest thing that stops it — +/// one marker eviction has to read. +fn hold( + harness: &Harness, + target: &Path, + backup: Option<&str>, + reason: Option<&str>, +) -> Result<()> { + let (resolved, pool) = pool_of(harness, target)?; + let reference = named_slot(&pool, backup, "hold")?; + // A pool can be held by more than one run. Without a reason, whoever meets + // a full pool knows which slots to release and not what releasing one + // would cost. + let reason = reason.unwrap_or("no reason recorded"); + if pool.hold(&reference, reason)? { + println!( + "{} is held ({reason}). Retention will not reclaim it until it is released.", + reference.as_str() + ); + } else { + let why = pool + .held_reason(&reference)? + .unwrap_or_else(|| "no reason recorded".to_owned()); + println!("{} was already held ({why}).", reference.as_str()); + } + println!( + " release it with: release --backup {} --target {}", + reference.as_str(), + resolved.root().display() + ); + Ok(()) +} + +/// Let retention have a held backup back. +fn release(harness: &Harness, target: &Path, backup: Option<&str>) -> Result<()> { + let (_, pool) = pool_of(harness, target)?; + let reference = named_slot(&pool, backup, "release")?; + if pool.release(&reference)? { + println!( + "{} is released and will be reclaimed like any other slot.", + reference.as_str() + ); + } else { + // Not a refusal: a run cleaning up after itself should not have to tell + // "nothing to do" apart from "something is wrong". + println!("{} was not held; nothing to release.", reference.as_str()); + } + Ok(()) +} + +fn pool_of(harness: &Harness, target: &Path) -> Result<(Target, Pool)> { + let resolved = Target::resolve(target, harness.control_directory)?; + let pool = Pool::observe(&resolved.control_directory(), facts::BACKUP_SLOTS)?; + Ok((resolved, pool)) +} + +/// The slot a caller named, or the list of the ones they could have named. +/// +/// Never inferred. `restore` may default to the newest because that is the one +/// thing a caller wanting "undo" can mean; keeping a slot is a decision about a +/// specific capture, and guessing which would be guessing what a run is for. +fn named_slot( + pool: &Pool, + backup: Option<&str>, + verb: &str, +) -> Result { + let available = pool.list()?; + let Some(text) = backup else { + return Err(local(if available.is_empty() { + format!("{verb} requires --backup , and this target has no backups") + } else { + format!( + "{verb} requires --backup ; this target holds {}", + available + .iter() + .map(|record| record.backup_ref.as_str()) + .collect::>() + .join(", ") + ) + })); + }; + setup_core::backup::BackupRef::parse(text) + .map_err(|error| local(format!("{text:?} is not a backup reference: {error}"))) +} + /// What the program directory holds, and which version answers to the command. /// /// Install and update are not here, and their absence is the design rather than @@ -562,12 +701,16 @@ fn backups(harness: &Harness, target: &Path) -> Result<()> { } println!("Backups of {}, newest first:", resolved.root().display()); println!(); + let pool = Pool::observe(&resolved.control_directory(), facts::BACKUP_SLOTS)?; for (position, record) in records.iter().enumerate() { - let marker = if position == 0 { - " (restored by default)" - } else { - "" - }; + let mut marker = String::new(); + if position == 0 { + marker.push_str(" (restored by default)"); + } + if let Some(why) = pool.held_reason(&record.backup_ref)? { + use std::fmt::Write as _; + let _ = write!(marker, " (held: {why})"); + } println!(" {}{marker}", record.backup_ref.as_str()); println!( " before {}, setup {}", diff --git a/crates/harness-runtime/src/lib.rs b/crates/harness-runtime/src/lib.rs index de51fb4..b3341c0 100644 --- a/crates/harness-runtime/src/lib.rs +++ b/crates/harness-runtime/src/lib.rs @@ -198,6 +198,8 @@ fn print_help(harness: &Harness) { println!(" diff --target "); println!(" backups --target "); println!(" restore [--backup ] --target "); + println!(" hold --backup [--reason ] --target "); + println!(" release --backup --target "); println!(" remove --target "); if !harness.predecessor_state_file.is_empty() { println!(" adopt --target "); @@ -231,6 +233,88 @@ fn print_help(harness: &Harness) { println!("previous, only what is on disk."); println!(); println!("A backup is captured before every change, so `restore` always has"); - println!("something to return to. Over the wire, install and replace arrive as"); - println!("a bundle and refuse -- this build reads setups from its own catalog."); + println!("something to return to. The pool rolls, so a long series of changes"); + println!("eventually evicts the oldest: `hold` keeps one until `release` lets"); + println!("it go, which is how a baseline survives more captures than the pool."); + println!("Over the wire, install and replace arrive as a bundle and refuse --"); + println!("this build reads setups from its own catalog."); +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + /// The entry point uses the toolchain this tree pins, even where a + /// different one comes first on `PATH`. + /// + /// This was closed once by *running* it: with `~/.local/bin` first, + /// `cargo test --doc` failed with `E0514` and `scripts/gate.sh` reported + /// the pinned version anyway. A run is not a test. The next + /// `rust-toolchain.toml` bump is when a silently wrong entry point costs + /// something, and until now nothing would have been watching. + /// + /// The shim is the shadow the trap describes: a real executable named + /// `cargo`, earlier on `PATH`, reporting a different release. The test + /// proves it *would* have won, and then that the entry point selects past + /// it — otherwise a passing assertion could mean the shim was never + /// consulted at all. + #[test] + #[cfg(unix)] + fn the_entry_point_selects_the_pinned_toolchain_past_a_shadow() { + use std::os::unix::fs::PermissionsExt; + use std::process::Command; + + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let pinned = std::fs::read_to_string(root.join("rust-toolchain.toml")) + .unwrap() + .lines() + .find_map(|line| { + line.strip_prefix("channel = \"") + .and_then(|rest| rest.strip_suffix('"')) + .map(str::to_owned) + }) + .expect("rust-toolchain.toml pins a channel"); + + let shadow = std::env::temp_dir().join(format!("gate-shadow-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&shadow); + std::fs::create_dir_all(&shadow).unwrap(); + let shim = shadow.join("cargo"); + std::fs::write(&shim, "#!/bin/sh\necho 'cargo 1.0.0 (shadow)'\n").unwrap(); + std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let shadowed = format!( + "{}:{}", + shadow.display(), + std::env::var("PATH").unwrap_or_default() + ); + + // The shim would have won. Without this the assertion below could pass + // because nothing ever put it in the way. + let shadowing = Command::new("sh") + .arg("-c") + .arg("cargo --version") + .env("PATH", &shadowed) + .output() + .unwrap(); + assert!( + String::from_utf8_lossy(&shadowing.stdout).contains("1.0.0 (shadow)"), + "the shim did not shadow cargo, so this test proves nothing" + ); + + let asked = Command::new("bash") + .arg("scripts/gate.sh") + .arg("--toolchain") + .current_dir(&root) + .env("PATH", &shadowed) + .output() + .unwrap(); + let said = String::from_utf8_lossy(&asked.stdout).into_owned(); + let _ = std::fs::remove_dir_all(&shadow); + + assert!(asked.status.success(), "the entry point failed: {said}"); + assert!( + said.contains(&format!("cargo reports {pinned}")), + "the entry point used the shadow rather than the pinned {pinned}: {said}" + ); + } } diff --git a/crates/harness-runtime/src/software.rs b/crates/harness-runtime/src/software.rs index 74fa166..7fd2e66 100644 --- a/crates/harness-runtime/src/software.rs +++ b/crates/harness-runtime/src/software.rs @@ -116,14 +116,21 @@ pub(crate) fn plan( let root = program_directory(prefix, operation)?; version_for(&declared, software_version)?; - let entry_point = format!("bin/{}", declared.command); + // Not always the command: pi's entry point is JavaScript, and Windows runs + // a file by its extension rather than by a shebang, so what is exposed + // there is `pi.cmd`. The plan states what a caller will actually be able to + // run, which is the whole point of naming it. + let entry_point = format!( + "bin/{}", + setup_core::software::exposed_name(declared.command, declared.member_hint()) + ); let exposed = root.join(&entry_point); // Planning may read the local disk and may not reach the network, so what // is already under the prefix belongs in the plan. Without it an install // and an update produced byte-identical effects -- two names for one act, // and neither said what was about to be replaced. - let present = software::Present::under(&root, declared.command); + let present = software::Present::under_named(&root, declared.command, declared.member_hint()); if operation == Operation::SoftwareRemove { let mut effects = vec![ @@ -285,7 +292,7 @@ pub(crate) fn apply( // the prefix could have been emptied in between. The plan's digest binds // what was decided, not what the disk still holds. if operation == Operation::SoftwareUpdate - && software::Present::under(&root, declared.command) + && software::Present::under_named(&root, declared.command, declared.member_hint()) .versions .is_empty() { @@ -309,7 +316,10 @@ pub(crate) fn apply( "operation": operation.as_str(), "command": declared.command, "version": installed.version, - "entry_point": format!("bin/{}", declared.command), + "entry_point": format!( + "bin/{}", + setup_core::software::exposed_name(declared.command, declared.member_hint()) + ), "executable": installed.executable.to_string_lossy(), "files": installed.files, })) @@ -360,7 +370,13 @@ pub(crate) fn launch( let declared = declared(harness)?; let root = program_directory(prefix, Operation::Launch)?; - let executable = root.join("bin").join(declared.command); + // The same name the plan stated and the apply wrote. Three readings of one + // fact, and they have to be one expression or they will drift apart on the + // one platform where they differ. + let executable = root.join("bin").join(setup_core::software::exposed_name( + declared.command, + declared.member_hint(), + )); let found = executable.metadata().map_err(|error| { Error::refuse( diff --git a/crates/harness-runtime/src/wire.rs b/crates/harness-runtime/src/wire.rs index d21f0f2..ae6121f 100644 --- a/crates/harness-runtime/src/wire.rs +++ b/crates/harness-runtime/src/wire.rs @@ -2029,6 +2029,241 @@ mod tests { assert_eq!(plan_then_apply(&target, "backup", &[])["state"], "verified"); } + /// A configuration operation is bound to the target it planned against; a + /// software operation is not, and that difference is load-bearing. + /// + /// `perform` re-checks `expected_target_digest` under the lock and refuses + /// `Stale` when the target moved. Software operations do not go through it + /// at all, so a configuration edit between plan and apply does **not** + /// strand a program install — which is correct: a program lives under + /// `--prefix` and has nothing to do with the configuration in the target, + /// and binding them would let someone editing their own instructions + /// invalidate a download of a hundred and sixty megabytes. + /// + /// It was correct, deliberate, undocumented and held by nothing. The + /// consumer has been told they may rely on it, so it is asserted from both + /// sides here: routing software through `perform` "for uniformity" is a + /// reasonable-looking cleanup that would break them silently, with every + /// existing test still green. + #[test] + fn a_configuration_edit_strands_a_configuration_plan_and_not_a_program_one() { + // The software side: plan, edit the target, apply, and it still lands. + let target = seeded("precondition-software"); + let file = downloaded(&target, TEST_PAYLOAD); + let planned = software_plan(&target, "software_install"); + let plan_path = target.join("..").join("precondition-plan.json"); + fs::write( + &plan_path, + setup_core::canonical::to_canonical_bytes(&planned["plan"]).unwrap(), + ) + .unwrap(); + + fs::write( + target.join("AGENTS.md"), + "# edited between plan and apply\n", + ) + .unwrap(); + + let prefix = target.join("..").join("precondition-prefix"); + let applied = run(args( + "apply-operation", + &target, + &[ + "--plan", + &plan_path.to_string_lossy(), + "--plan-digest", + planned["plan_digest"].as_str().unwrap(), + "--provider-release-digest", + RELEASE, + "--prefix", + &prefix.to_string_lossy(), + "--software-artifact", + &file.to_string_lossy(), + ], + )); + assert_eq!( + applied["state"], "verified", + "a configuration edit stranded a program install" + ); + + // The configuration side, same edit, and it must refuse: a plan that + // authorized one target state cannot be applied to another. + let other = seeded("precondition-configuration"); + let config_plan = run(args( + "plan-operation", + &other, + &[ + "--operation", + "backup", + "--provider-release-digest", + RELEASE, + "--operation-id", + "operation_01PRECOND", + "--expires-at", + far_future(), + ], + )); + let config_path = other.join("..").join("precondition-config-plan.json"); + fs::write( + &config_path, + setup_core::canonical::to_canonical_bytes(&config_plan["plan"]).unwrap(), + ) + .unwrap(); + + fs::write(other.join("AGENTS.md"), "# edited between plan and apply\n").unwrap(); + + let error = refuse(args( + "apply-operation", + &other, + &[ + "--plan", + &config_path.to_string_lossy(), + "--plan-digest", + config_plan["plan_digest"].as_str().unwrap(), + "--provider-release-digest", + RELEASE, + ], + )); + assert_eq!( + error.reason(), + Some(WireReason::Stale), + "a configuration plan survived the target changing under it: {}", + error.detail() + ); + } + + /// Holds a file unreadable for as long as it lives, and puts it back. + /// + /// Two mechanisms, one condition. On Windows a file another process holds + /// open with no sharing cannot be opened at all; on Unix the same effect + /// comes from permissions. A guard rather than a bare call so the condition + /// cannot outlive the test that wanted it. + struct Unreadable { + #[cfg(windows)] + _handle: fs::File, + #[cfg(unix)] + path: PathBuf, + } + + impl Unreadable { + /// `None` when this process can still read the file afterwards, which + /// is what running as root looks like. A test that cannot create its + /// condition must say so rather than pass. + fn of(path: &Path) -> Option { + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + // Deny every kind of sharing: what a running product does to a + // database or a log it owns. + let handle = fs::OpenOptions::new() + .read(true) + .share_mode(0) + .open(path) + .ok()?; + fs::File::open(path) + .is_err() + .then_some(Self { _handle: handle }) + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o000)).ok()?; + if fs::File::open(path).is_ok() { + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o644)); + return None; + } + Some(Self { + path: path.to_path_buf(), + }) + } + } + } + + impl Drop for Unreadable { + fn drop(&mut self) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&self.path, fs::Permissions::from_mode(0o644)); + } + } + } + + /// A file inside an owned namespace that cannot be read stops the operation + /// before it starts, names the file, and leaves nothing behind. + /// + /// `G12` left this open and `run_once_it_is_not_busy` was never it: that is + /// a Linux `ETXTBSY` fork race the test harness creates by writing and + /// exec'ing in one multi-threaded process, and its own comment says so. + /// + /// The real condition is a product holding its own file open — a database, + /// a log — inside a namespace this provider owns. It is reachable on both + /// systems and it is the same condition: on Windows through a share mode + /// that denies everything, on Unix through permissions. + /// + /// What must be true is not that the operation succeeds. A slot that + /// silently skipped a file it could not read would not restore the target, + /// which is worse than refusing. What must be true is that the refusal + /// **names the file** and that nothing partial survives it: identity is + /// computed before any capture, so a target that cannot be read is a target + /// nothing has been done to. + #[test] + fn a_file_this_process_cannot_read_stops_the_operation_and_leaves_nothing() { + let target = seeded("unreadable"); + plan_then_apply(&target, "backup", &[]); + let control = target.join(TEST.control_directory); + let before = fs::read_dir(control.join("backups")).map_or(0, Iterator::count); + + let locked = target.join("skills").join("held-open.md"); + fs::write(&locked, "a product owns this").unwrap(); + let Some(held) = Unreadable::of(&locked) else { + // Cannot create the condition here — running as root, or the + // platform declined. Saying so beats a green that proves nothing. + panic!( + "this process can still read a file it made unreadable, so this test \ + would prove nothing; it needs to run as a user permissions apply to" + ); + }; + + let error = refuse(args( + "plan-operation", + &target, + &[ + "--operation", + "backup", + "--provider-release-digest", + RELEASE, + "--operation-id", + "operation_01LOCKED", + "--expires-at", + far_future(), + ], + )); + assert!( + error.detail().contains("held-open.md"), + "the refusal does not name the file it could not read: {}", + error.detail() + ); + + // Nothing started. No new slot, no journal, no transaction to recover. + assert_eq!( + fs::read_dir(control.join("backups")).map_or(0, Iterator::count), + before, + "a refused operation left a backup slot behind" + ); + assert!(!control.join("journal.json").exists()); + assert!(!control.join("transaction").exists()); + + // And it recovers by itself once the file is readable again: nothing + // was recorded that has to be undone. + drop(held); + assert_eq!( + plan_then_apply(&target, "backup", &[])["state"], + "verified", + "the target did not become usable again once the file could be read" + ); + } + #[test] fn two_status_calls_return_the_same_bytes() { // The consumer calls it twice and requires the answers to be identical, diff --git a/crates/setup-core/src/backup.rs b/crates/setup-core/src/backup.rs index a3ebe79..48c47d9 100644 --- a/crates/setup-core/src/backup.rs +++ b/crates/setup-core/src/backup.rs @@ -30,6 +30,20 @@ pub const SLOT_MARKER_NAME: &str = "slot.json"; /// The subdirectory holding the captured tree. pub const SLOT_PAYLOAD_NAME: &str = "payload"; +/// The marker naming a slot that retention may not reclaim. +/// +/// Beside the record rather than inside it, on purpose. The record states what +/// was *captured*; a hold states whether retention may take it back. Those are +/// two different facts about one slot, and writing the second into the first +/// would mean editing a completed capture every time someone changed their mind +/// about keeping it. +/// +/// The file holds the reason the hold was placed. A pool can be held by more +/// than one run at a time, and a refusal that says only *which* slots are held +/// leaves the next caller releasing one blind — possibly one another run is +/// still depending on. +pub const SLOT_HELD_NAME: &str = "HELD"; + /// The schema this kernel writes and is willing to read. pub const SLOT_SCHEMA: u32 = 1; @@ -329,6 +343,148 @@ impl Pool { Ok(record) } + /// Keep one slot until it is explicitly released. + /// + /// The pool rolls: ten slots, oldest evicted. A long evidence series makes + /// more captures than that, so the baseline it means to return to at the + /// end is gone by the time it gets there — reported after a run of fifty + /// captures could not restore the state it started from. + /// + /// A hold is the smallest thing that fixes it: one marker that eviction has + /// to read. Against an export/import format, which is a second format to + /// keep correct, and against a separate baseline store, which is a second + /// place state lives. With a hold, *retention never silently changes what a + /// `BackupRef` names* becomes a checkable statement rather than an + /// intention. + /// + /// # Errors + /// + /// Refuses a reference this pool does not hold, and refuses a hold that + /// would leave the pool no slot to rotate — ten held slots is a target that + /// can never be backed up again, which is a worse failure than the eviction + /// it was protecting against. The refusal names what is already held so a + /// caller knows what to release. + pub fn hold(&self, backup_ref: &BackupRef, reason: &str) -> Result { + let slot = self.root.join(backup_ref.as_str()); + if read_record(&slot)?.is_none() { + return Err(Error::new( + ReasonCode::InvalidTarget, + format!( + "{} is not a completed slot in this pool", + backup_ref.as_str() + ), + )); + } + let already = self.held()?; + if already.iter().any(|(held, _)| held == backup_ref) { + return Ok(false); + } + // One slot must stay reclaimable, or the next capture has nothing to + // evict and the pool grows past the bound it was opened with. + if already.len() + 1 >= self.capacity { + return Err(Error::new( + ReasonCode::InvalidTarget, + format!( + "holding {} would leave this pool of {} no slot to rotate; release one of \ + these first, and the reason each names is who would lose it: {}", + backup_ref.as_str(), + self.capacity, + already + .iter() + .map(|(held, why)| format!("{} ({why})", held.as_str())) + .collect::>() + .join(", ") + ), + )); + } + // The reason travels with the hold. Without it a caller reading a full + // pool knows what to release and not what releasing it would cost. + fs::write(slot.join(SLOT_HELD_NAME), reason.as_bytes()).map_err(|source| { + Error::new( + ReasonCode::StateUnavailable, + format!("cannot hold {}", slot.display()), + ) + .with_source(source) + })?; + Ok(true) + } + + /// Let retention have a slot back. + /// + /// Answers `false` for a slot that was not held rather than refusing: a run + /// cleaning up after itself should not have to tell "nothing to do" apart + /// from "something is wrong", which is the same reason `remove` answers + /// `removed: false` on a prefix it never wrote. + /// + /// # Errors + /// + /// Returns [`ReasonCode::StateUnavailable`] if the marker cannot be removed. + pub fn release(&self, backup_ref: &BackupRef) -> Result { + let marker = self.root.join(backup_ref.as_str()).join(SLOT_HELD_NAME); + match fs::remove_file(&marker) { + Ok(()) => Ok(true), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(Error::new( + ReasonCode::StateUnavailable, + format!("cannot release {}", marker.display()), + ) + .with_source(source)), + } + } + + /// Every slot retention may not reclaim, newest first, with why. + /// + /// # Errors + /// + /// Propagates the refusal from [`Pool::list`]. + pub fn held(&self) -> Result> { + let mut out = Vec::new(); + for record in self.list()? { + let marker = self + .root + .join(record.backup_ref.as_str()) + .join(SLOT_HELD_NAME); + if let Ok(reason) = fs::read_to_string(&marker) { + let reason = reason.trim().to_owned(); + let reason = if reason.is_empty() { + // A hold placed before reasons were carried, or one whose + // caller gave none. Saying so beats an empty parenthesis. + "no reason recorded".to_owned() + } else { + reason + }; + out.push((record.backup_ref, reason)); + } + } + Ok(out) + } + + /// Why one slot is held, when it is. + /// + /// # Errors + /// + /// Propagates the refusal from [`Pool::list`]. + pub fn held_reason(&self, backup_ref: &BackupRef) -> Result> { + Ok(self + .held()? + .into_iter() + .find(|(held, _)| held == backup_ref) + .map(|(_, reason)| reason)) + } + + /// Whether one slot is held. + /// + /// # Errors + /// + /// Never; the signature matches its neighbours so a caller reads them alike. + pub fn is_held(&self, backup_ref: &BackupRef) -> Result { + Ok(self + .root + .join(backup_ref.as_str()) + .join(SLOT_HELD_NAME) + .is_file()) + } + /// Drop the oldest completed slots beyond the pool capacity. /// /// Incomplete slots are never pruned: they are evidence recovery needs, and @@ -338,7 +494,15 @@ impl Pool { /// /// Returns [`ReasonCode::StateUnavailable`] if a slot cannot be removed. pub fn prune(&self) -> Result<()> { - let records = self.list()?; + // Held slots are not counted against the bound and are never reclaimed. + // Counting them would make a hold quietly shorten the rolling window + // instead of protecting one capture, and the caller asked for the + // second thing. + let records: Vec = self + .list()? + .into_iter() + .filter(|record| !self.is_held(&record.backup_ref).unwrap_or(false)) + .collect(); for record in records.into_iter().skip(self.capacity) { let slot = self.root.join(record.backup_ref.as_str()); fs::remove_dir_all(&slot).map_err(|source| { @@ -682,6 +846,111 @@ mod tests { assert_eq!(error.reason(), ReasonCode::IntegrityMismatch); } + /// The claim `#428` asks for: a held baseline survives a run longer than + /// the pool. + /// + /// The pool rolls at ten. A series that captures fifty times loses the + /// state it started from long before it gets back to it — reported after + /// exactly that, where the final restore had nothing to restore to. + #[test] + fn a_held_slot_outlives_far_more_captures_than_the_pool_holds() { + let base = scratch("held-baseline"); + let target = base.join("target"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("a.txt"), "the baseline").unwrap(); + + let pool = Pool::open(&base.join("control"), 10).unwrap(); + let baseline = pool.capture(&target, &["a.txt"], record_for).unwrap(); + assert!( + pool.hold(&baseline.backup_ref, "E00 baseline for the evidence series") + .unwrap() + ); + + // Fifty more, which is five times the pool. + for round in 0..50 { + fs::write(target.join("a.txt"), format!("round {round}")).unwrap(); + pool.capture(&target, &["a.txt"], record_for).unwrap(); + pool.prune().unwrap(); + } + + let held = pool.held().unwrap(); + assert_eq!(held.len(), 1); + assert_eq!(held[0].0, baseline.backup_ref); + assert_eq!(held[0].1, "E00 baseline for the evidence series"); + assert!( + pool.payload_of(&baseline.backup_ref).is_ok(), + "the held baseline was reclaimed by retention" + ); + assert_eq!( + fs::read_to_string(pool.payload_of(&baseline.backup_ref).unwrap().join("a.txt")) + .unwrap(), + "the baseline", + "the held slot survived but no longer names what it named" + ); + + // And the hold did not quietly shorten the rolling window: ten + // unheld slots are still there beside it. + let unheld: Vec<_> = pool + .list() + .unwrap() + .into_iter() + .filter(|r| !pool.is_held(&r.backup_ref).unwrap()) + .collect(); + assert_eq!(unheld.len(), 10); + + // Released, it becomes ordinary and the next prune reclaims it. + assert!(pool.release(&baseline.backup_ref).unwrap()); + assert!(!pool.release(&baseline.backup_ref).unwrap()); + pool.prune().unwrap(); + assert!(pool.payload_of(&baseline.backup_ref).is_err()); + } + + /// A pool that is entirely held is a target that can never be backed up + /// again, which is a worse failure than the eviction a hold prevents. + #[test] + fn a_hold_that_would_leave_nothing_to_rotate_is_refused_naming_what_to_release() { + let base = scratch("held-full"); + let target = base.join("target"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("a.txt"), "x").unwrap(); + + let pool = Pool::open(&base.join("control"), 3).unwrap(); + let first = pool.capture(&target, &["a.txt"], record_for).unwrap(); + let second = pool.capture(&target, &["a.txt"], record_for).unwrap(); + let third = pool.capture(&target, &["a.txt"], record_for).unwrap(); + + assert!(pool.hold(&first.backup_ref, "series A baseline").unwrap()); + assert!(pool.hold(&second.backup_ref, "series B baseline").unwrap()); + // Holding a third of three would leave nothing to evict. + let error = pool.hold(&third.backup_ref, "series C").unwrap_err(); + assert!(error.to_string().contains("no slot to rotate"), "{error}"); + assert!( + error.to_string().contains(first.backup_ref.as_str()), + "the refusal does not say what to release: {error}" + ); + // And what releasing it would cost, so nobody releases blind. + assert!( + error.to_string().contains("series A baseline"), + "the refusal does not say who holds it: {error}" + ); + + // Holding one that is already held is not an error and not a second + // hold: a run that re-runs its own setup should not have to check. + assert!(!pool.hold(&first.backup_ref, "series A again").unwrap()); + } + + /// A reference this pool never minted is refused rather than marked. + #[test] + fn holding_a_slot_that_is_not_here_is_refused() { + let base = scratch("held-absent"); + let pool = Pool::open(&base.join("control"), 3).unwrap(); + let absent = BackupRef::parse("slot-000000000009").unwrap(); + let error = pool.hold(&absent, "nothing").unwrap_err(); + assert!(error.to_string().contains("slot-000000000009"), "{error}"); + // And releasing one that is not here is still not an error. + assert!(!pool.release(&absent).unwrap()); + } + #[test] fn capture_records_the_slot_and_latest_names_it() { let base = scratch("capture"); diff --git a/crates/setup-core/src/software.rs b/crates/setup-core/src/software.rs index d2f81db..fb1256b 100644 --- a/crates/setup-core/src/software.rs +++ b/crates/setup-core/src/software.rs @@ -129,6 +129,16 @@ impl Present { /// plan for a prefix that does not exist yet is exactly the ordinary case. #[must_use] pub fn under(root: &Path, command: &str) -> Self { + Self::under_named(root, command, "") + } + + /// The same reading, told which member the exposed thing was made from. + /// + /// Windows exposes a JavaScript entry point as `.cmd`, so a + /// reading that only ever looked for `` would report nothing + /// exposed on exactly the harness that needed the launcher. + #[must_use] + pub fn under_named(root: &Path, command: &str, member: &str) -> Self { let mut versions: Vec = fs::read_dir(root) .into_iter() .flatten() @@ -170,7 +180,8 @@ impl Present { // remembers what `expose` last pointed at, and someone can break that // by hand afterwards. `metadata` follows links, so this is false for a // dangling one and true for both a real file and a live link. - let usable = fs::metadata(root.join("bin").join(command)).is_ok(); + let exposed_as = exposed_name(command, member); + let usable = fs::metadata(root.join("bin").join(&exposed_as)).is_ok(); let marker = Self::marker(root, command); let exposed = fs::read_to_string(&marker) .ok() @@ -183,7 +194,7 @@ impl Present { // Nothing recorded: a prefix written before this existed, or one // someone arranged themselves. Where a real link is what is // there, reading it is still the truth. - fs::canonicalize(root.join("bin").join(command)) + fs::canonicalize(root.join("bin").join(&exposed_as)) .ok() .zip(fs::canonicalize(root).ok()) .and_then(|(to, base)| { @@ -408,7 +419,9 @@ pub fn install( } }; - let exposed = root.join("bin").join(software.command); + let exposed = root + .join("bin") + .join(exposed_name(software.command, artifact.member)); expose(&executable, &exposed, software.version, software.command)?; Ok(Installed { @@ -439,7 +452,9 @@ pub fn remove(software: &Software, root: &Path) -> Result { ) .with_source(error) })?; - let exposed = root.join("bin").join(software.command); + let exposed = root + .join("bin") + .join(exposed_name(software.command, software.member_hint())); if exposed.symlink_metadata().is_ok() { fs::remove_file(&exposed).map_err(|error| { Error::new( @@ -479,7 +494,7 @@ pub fn remove(software: &Software, root: &Path) -> Result { /// Refuses a version that is not installed, naming the ones that are, and a /// version tree that holds no executable this build can find. pub fn rollback(software: &Software, root: &Path, to: &str) -> Result { - let present = Present::under(root, software.command); + let present = Present::under_named(root, software.command, software.member_hint()); if !present.versions.iter().any(|found| found == to) { return Err(Error::new( ReasonCode::InvalidTarget, @@ -524,7 +539,9 @@ pub fn rollback(software: &Software, root: &Path, to: &str) -> Result )); }; - let exposed = root.join("bin").join(software.command); + let exposed = root + .join("bin") + .join(exposed_name(software.command, software.member_hint())); expose(executable, &exposed, to, software.command)?; Ok(Installed { version: to.to_owned(), @@ -534,6 +551,49 @@ pub fn rollback(software: &Software, root: &Path, to: &str) -> Result }) } +/// The name the exposed command answers to, which is not always the command. +/// +/// Six of the seven products ship a native executable, and a link named after +/// the command is the whole story. Pi ships JavaScript — `dist/bundle/cli.js`, +/// with a `#!/usr/bin/env node` line and mode 755 — and the two systems part +/// company there. +/// +/// On Unix the shebang does the work and a symlink named `pi` runs. Windows has +/// no shebang: it decides how to run a file from its extension, and a copy +/// named `pi` with JavaScript inside it is not a program. So there the exposed +/// thing is `pi.cmd`, a launcher that names the interpreter. +/// +/// Said in one place because three callers need the same answer: the plan that +/// states `entry_point`, the apply that writes it, and `launch` that starts it. +/// They disagreed once already about where a program lives, and once was +/// enough. +#[must_use] +pub fn exposed_name(command: &str, member: &str) -> String { + exposed_name_on(command, member, cfg!(windows)) +} + +/// The same rule, with the platform as an argument rather than as a `cfg`. +/// +/// Written this way so it can be *asserted* from either system rather than +/// demonstrated on one. A `cfg!` here would make the Windows branch provable +/// only on Windows, and a mutation that deleted it would leave every Linux run +/// green — which is exactly what happened to the first version of this, and is +/// the reason it is a parameter now. +#[must_use] +pub fn exposed_name_on(command: &str, member: &str, windows: bool) -> String { + // Through the extension rather than the spelling: Windows treats `.JS` and + // `.js` as one extension, and a rule that missed the first would expose an + // unrunnable file on the only platform this branch exists for. + let javascript = Path::new(member) + .extension() + .is_some_and(|kind| kind.eq_ignore_ascii_case("js")); + if windows && javascript { + format!("{command}.cmd") + } else { + command.to_owned() + } +} + /// Point one stable path at the executable inside a versioned tree. /// /// The member is left where the archive put it. Codex's binary needs the `rg` @@ -562,12 +622,26 @@ fn expose(executable: &Path, exposed: &Path, version: &str, command: &str) -> Re } #[cfg(not(unix))] { - // Windows reserves symlink creation for privileged or developer-mode - // processes, so a hard link is what actually works; a copy is the last - // resort and costs a second copy of a large binary. - fs::hard_link(executable, exposed) - .or_else(|_| fs::copy(executable, exposed).map(|_| ())) + if exposed.extension().is_some_and(|kind| kind == "cmd") { + // A launcher rather than a link. Windows runs a file by its + // extension, so neither a hard link nor a copy of a `.js` is a + // program -- and the interpreter has to be named. `%*` forwards + // every argument, and the quotes survive a prefix with spaces, + // which `%LOCALAPPDATA%\Programs` is one bad default away from. + fs::write( + exposed, + format!("@node \"{}\" %*\r\n", executable.display()), + ) .map_err(fail)?; + } else { + // Windows reserves symlink creation for privileged or + // developer-mode processes, so a hard link is what actually works; + // a copy is the last resort and costs a second copy of a large + // binary. + fs::hard_link(executable, exposed) + .or_else(|_| fs::copy(executable, exposed).map(|_| ())) + .map_err(fail)?; + } } // Which version this now runs, recorded rather than left to be inferred @@ -654,6 +728,88 @@ mod tests { ); } + /// A JavaScript entry point is exposed as something the platform can run. + /// + /// Six of the seven products ship a native executable and a link named + /// after the command is the whole story. Pi ships `dist/bundle/cli.js`, and + /// the two systems part company: Unix has the shebang, Windows decides how + /// to run a file from its extension, so a copy named `pi` with JavaScript + /// inside it is not a program there. + /// + /// The rule is asserted for both systems from either, because it is the + /// answer three callers need to agree on -- the plan that states + /// `entry_point`, the apply that writes it, and `launch` that starts it. + #[test] + fn a_javascript_entry_point_is_exposed_as_something_the_platform_can_run() { + // A native member is the command, on every system. + assert_eq!(exposed_name("codex", CODEX_MEMBER), "codex"); + assert_eq!(exposed_name("grok", ""), "grok"); + + // A JavaScript member, asserted for both systems from whichever one is + // running: Windows cannot run a file named `pi` holding JavaScript, and + // on Unix the shebang does the work so a launcher would be noise. + let js = "package/dist/bundle/cli.js"; + assert_eq!(exposed_name_on("pi", js, true), "pi.cmd"); + assert_eq!(exposed_name_on("pi", js, false), "pi"); + + // A native member is unaffected by the platform, which is what makes + // this a rule about the artifact rather than about Windows. + assert_eq!(exposed_name_on("codex", CODEX_MEMBER, true), "codex"); + assert_eq!(exposed_name_on("codex", CODEX_MEMBER, false), "codex"); + + // And the shipped entry point agrees with the rule for this system. + assert_eq!( + exposed_name("pi", js), + exposed_name_on("pi", js, cfg!(windows)) + ); + } + + /// Pi installs from one tarball and the thing that lands runs. + /// + /// The declaration this replaces said pi could not be installed because + /// "its dependency closure is resolved at install time, so there is no + /// single artifact whose digest can be decided in advance". The published + /// package ships `npm-shrinkwrap.json`, so the closure is fixed -- and it + /// does not matter, because the bundle imports only Node built-ins and runs + /// with no `node_modules` at all. + #[test] + #[cfg(unix)] + fn a_javascript_program_installs_from_one_archive_and_runs() { + let member = "package/dist/bundle/cli.js"; + let (at, artifact) = staged( + "js-entry", + b"#!/usr/bin/env sh\necho 'js-stand-in 9.9.9'\n", + member, + ); + let software = Software { + version: "9.9.9", + command: "jsprog", + delivery: Delivery::Artifacts(&[]), + unsupported: &[], + }; + let root = at.join("prefix"); + let installed = install(&software, &artifact, &at.join("artifact.tgz"), &root).unwrap(); + + // Exposed under the name the rule gives, pointing into the archive's + // own layout rather than a copy lifted out of it. + assert_eq!( + installed.executable, + root.join("bin").join(exposed_name("jsprog", member)) + ); + assert_eq!( + fs::canonicalize(&installed.executable).unwrap(), + fs::canonicalize(root.join("9.9.9").join(member)).unwrap() + ); + + // And the reading of what is exposed agrees with what was written. + assert_eq!( + Present::under_named(&root, "jsprog", member) + .exposed + .as_deref(), + Some("9.9.9") + ); + } + /// The exposed version is recorded, so it is readable where no link exists. /// /// This is the Windows defect written as a test that fails on Linux too. diff --git a/install.ps1 b/install.ps1 index b9dd123..3462187 100644 --- a/install.ps1 +++ b/install.ps1 @@ -7,7 +7,7 @@ # powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.1.0 [CmdletBinding()] param( - [string]$Version = "0.0.5", + [string]$Version = "0.0.6", [string]$InstallDir = "$env:LOCALAPPDATA\Programs\opencode-setup-system" ) $ErrorActionPreference = "Stop" diff --git a/install.sh b/install.sh index 66461a2..421bc9e 100644 --- a/install.sh +++ b/install.sh @@ -14,7 +14,7 @@ set -eu REPO="NDDev-OpenNetwork/opencode-setup-system" BINARY="opencode-setup-system" -VERSION="${1:-0.0.5}" +VERSION="${1:-0.0.6}" PREFIX="${OPENCODE_INSTALL_DIR:-$HOME/.local/bin}" case "$(uname -s)" in diff --git a/references/opencode-baseline.json b/references/opencode-baseline.json index f28db0a..75f4778 100644 --- a/references/opencode-baseline.json +++ b/references/opencode-baseline.json @@ -90,7 +90,7 @@ "OPENCODE_DISABLE_CLAUDE_CODE", "OPENCODE_DISABLE_SHARE" ], - "verified_at": "2026-08-25T13:06:34+00:00", + "verified_at": "2026-08-26T23:01:06+00:00", "software_artifacts": { "command": "opencode", "shape": "gzip-tar", @@ -133,6 +133,6 @@ } }, "version": "1.18.23", - "verified_at": "2026-08-25T13:06:34+00:00" + "verified_at": "2026-08-26T23:01:06+00:00" } } From c0b665136574ef4db9ef26bd90113488719d9a65 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Thu, 27 Aug 2026 04:46:56 +0500 Subject: [PATCH 2/2] fix(test): a test that travelled somewhere its subject does not exist The toolchain entry point it asserts belongs to the authoring workspace, and a published tree ships no scripts/ at all -- so the test went looking for a file that exists in one of the eight places it runs. Windows passed, which is the tell: it is cfg(unix), so the platform that could not run it was the only one not reporting the mistake. Absence is asserted rather than skipped: a rendered tree carries no renderer either, so if both are gone there is nothing here to test, and if the script is gone while the renderer is not, someone deleted it. Rendered from NDDev-it-com/setup-systems@ebfa200. Claude-Session: https://claude.ai/code/session_012UZkeU7yQhyZqKAaDaQEjA --- crates/harness-runtime/src/lib.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/harness-runtime/src/lib.rs b/crates/harness-runtime/src/lib.rs index b3341c0..b098ee0 100644 --- a/crates/harness-runtime/src/lib.rs +++ b/crates/harness-runtime/src/lib.rs @@ -265,6 +265,29 @@ mod tests { use std::process::Command; let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + + // This crate is vendored into seven published trees, and the entry + // point it tests belongs to exactly one repository: the workspace where + // the code is written and the render is proved. A published tree ships + // no `scripts/` at all, because contributing there means something + // else -- so the test travelled somewhere its subject does not exist, + // and failed on ubuntu and macos in all seven at once. + // + // Absence is asserted rather than skipped. A rendered tree is + // identifiable: it carries no renderer either. If both are gone this is + // a published tree and there is nothing here to test; if the script is + // gone and the renderer is not, someone deleted the entry point in the + // workspace and that is a failure, not a skip. + if !root.join("scripts/gate.sh").is_file() { + assert!( + !root.join("tools/render_public_trees.py").is_file(), + "the toolchain entry point is missing from a workspace that still \ + renders the public trees; scripts/gate.sh was deleted rather than \ + never present" + ); + return; + } + let pinned = std::fs::read_to_string(root.join("rust-toolchain.toml")) .unwrap() .lines()