diff --git a/CHANGELOG.md b/CHANGELOG.md
index 30181a5..67097b5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,40 @@ sibling project the same week this note was added.
## [Unreleased]
+## [0.0.52] - 2026-09-01
+
+An operation that reads no bundle refuses one by name. Only
+`install` and `replace` read a bundle, and the plan bound the five bundle
+names into its artifact for every operation — so a `remove` plan carrying a
+fully-named bundle answered `planned, valid: true` with the digests echoed,
+and the apply removed everything with the bundle bytes untouched. Accept and
+ignore, measured on the released 0.0.50; the only loud refusal was the argv
+parser's, on a partial flag set.
+
+A plan that echoes inputs its apply will never read lies about what approving
+it means. The consumer's `end_state` design for `remove` (their ADR-0129)
+assumed the loud refusal existed for providers that do not yet declare the
+field; their declaration gate was in fact the only net. This release is the
+second: `unsupported_operation`, naming the two operations that do take a
+bundle. When `remove` learns to read one — the agreed `end_state` extension,
+kit 0.2.8+ — the refusal narrows to the operations that still read none.
+
+No released consumer sends a bundle on `remove`; their remove plans are built
+with no bundle bound, so nothing anyone runs changes behaviour under this
+release except the request that was already a contradiction.
+
+And the plan's software entry point names the member **this platform**
+actually gets. It was derived from the table's first row — a Unix member —
+so on Windows a cursor plan promised `bin/agent` while the apply, resolving
+this host's artifact, wrote `bin/agent.cmd`; `remove` looked for the wrong
+name too and left the launcher behind. Found by the consumer's six-leg
+matrix on `0.0.50`, Windows only, both architectures, cursor only — the one
+harness whose Windows member is a batch launcher while its Unix members are
+extensionless. Every reader of the exposed name — plan, apply's answer,
+launch, remove, rollback — now derives it from this platform's artifact,
+with the first-row hint kept only for a platform the vendor never published
+for.
+
## [0.0.51] - 2026-09-01
`remove` names both of its cases. It said *"anything you put under
diff --git a/Cargo.lock b/Cargo.lock
index b3096c7..578cb69 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -66,7 +66,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "harness-runtime"
-version = "0.0.51"
+version = "0.0.52"
dependencies = [
"provider-v3",
"serde",
@@ -128,7 +128,7 @@ dependencies = [
[[package]]
name = "opencode-setup-system"
-version = "0.0.51"
+version = "0.0.52"
dependencies = [
"harness-runtime",
"provider-v3",
@@ -147,7 +147,7 @@ dependencies = [
[[package]]
name = "provider-v3"
-version = "0.0.51"
+version = "0.0.52"
dependencies = [
"serde",
"serde_json",
@@ -209,7 +209,7 @@ dependencies = [
[[package]]
name = "setup-core"
-version = "0.0.51"
+version = "0.0.52"
dependencies = [
"miniz_oxide",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index be6f06b..df7c020 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,7 +8,7 @@ members = [
]
[workspace.package]
-version = "0.0.51"
+version = "0.0.52"
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.51" }
-provider-v3 = { path = "crates/provider-v3", version = "0.0.51" }
-harness-runtime = { path = "crates/harness-runtime", version = "0.0.51" }
+setup-core = { path = "crates/setup-core", version = "0.0.52" }
+provider-v3 = { path = "crates/provider-v3", version = "0.0.52" }
+harness-runtime = { path = "crates/harness-runtime", version = "0.0.52" }
[workspace.lints.rust]
unsafe_code = "forbid"
diff --git a/README.md b/README.md
index e3c7c29..07dbb80 100644
--- a/README.md
+++ b/README.md
@@ -179,7 +179,7 @@ release is a convenience, not the authorised copy.
```bash
docker run --rm -v "$HOME/.config:/config" \
- ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.51 \
+ ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.52 \
status --target /config/
--json
```
diff --git a/crates/harness-runtime/src/software.rs b/crates/harness-runtime/src/software.rs
index 44f0050..0e9f5a9 100644
--- a/crates/harness-runtime/src/software.rs
+++ b/crates/harness-runtime/src/software.rs
@@ -149,10 +149,17 @@ pub(crate) fn plan(
// 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.
+ // run, which is the whole point of naming it -- and it is derived from
+ // **this platform's** member, not the table's first row. The hint
+ // derivation promised `bin/agent` on Windows while the apply wrote
+ // `bin/agent.cmd`, because cursor's Windows member is a `.cmd` and its
+ // Unix members are extensionless; the consumer's matrix caught the plan
+ // lying on exactly the one platform where the two rows classify apart.
+ let (os, arch) = platform_of_this_host();
+ let member = declared.member_on(os, arch);
let entry_point = format!(
"bin/{}",
- setup_core::software::exposed_name(declared.command, declared.member_hint())
+ setup_core::software::exposed_name(declared.command, member)
);
let exposed = root.join(&entry_point);
@@ -160,7 +167,7 @@ pub(crate) fn plan(
// 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_named(&root, declared.command, declared.member_hint());
+ let present = software::Present::under_named(&root, declared.command, member);
if operation == Operation::SoftwareRemove {
let mut effects = vec![
@@ -205,7 +212,6 @@ pub(crate) fn plan(
));
}
- let (os, arch) = platform_of_this_host();
let artifact = declared.artifact_for(os, arch)?;
let mut effects = Vec::new();
if present.holds(declared.version) {
@@ -336,7 +342,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_named(&root, declared.command, declared.member_hint())
+ && software::Present::under_named(&root, declared.command, declared.member_here())
.versions
.is_empty()
{
@@ -368,7 +374,7 @@ pub(crate) fn apply(
"version": installed.version,
"entry_point": format!(
"bin/{}",
- setup_core::software::exposed_name(declared.command, declared.member_hint())
+ setup_core::software::exposed_name(declared.command, artifact.member)
),
"executable": installed.executable.to_string_lossy(),
"files": installed.files,
@@ -420,7 +426,7 @@ pub(crate) fn launch(
// one platform where they differ.
let executable = root.join("bin").join(setup_core::software::exposed_name(
declared.command,
- declared.member_hint(),
+ declared.member_here(),
));
let found = executable.metadata().map_err(|error| {
diff --git a/crates/harness-runtime/src/wire.rs b/crates/harness-runtime/src/wire.rs
index fb654dd..68ed319 100644
--- a/crates/harness-runtime/src/wire.rs
+++ b/crates/harness-runtime/src/wire.rs
@@ -525,6 +525,30 @@ fn honourable(harness: &Harness, request: &PlanRequest) -> Result<()> {
}
}
+ // The same rule for a bundle, and this one was worse than silently
+ // dropped: only `install` and `replace` read one, but the plan **bound**
+ // the five names into its artifact for every operation, so a remove plan
+ // echoed a `bundle_digest` its apply would never read. Measured on the
+ // released 0.0.50 while the consumer designed remove's `end_state`
+ // extension (their ADR-0129): plan `planned, valid: true`, apply removed
+ // everything, dummy bundle untouched -- accept and ignore, twice, exit 0.
+ // Their rollout story assumed the loud refusal existed; now it does. When
+ // remove learns to read a bundle (kit 0.2.8+, `end_state`), this narrows
+ // to the operations that still read none.
+ if request.bundle.is_some()
+ && !matches!(request.operation, Operation::Install | Operation::Replace)
+ {
+ return Err(Error::refuse(
+ WireReason::UnsupportedOperation,
+ format!(
+ "{} reads no bundle, so one named for it would be echoed into \
+ the plan and never read; install and replace are the \
+ operations that take one",
+ request.operation
+ ),
+ ));
+ }
+
// A profile this build never advertised cannot be honoured, and recording
// it in a plan would be worse than refusing: the apply would run under the
// only posture this build has while the artifact claimed another.
@@ -4610,6 +4634,50 @@ mod tests {
);
}
+ #[test]
+ fn a_bundle_on_an_operation_that_reads_none_is_refused_not_echoed() {
+ // Measured on the released 0.0.50 before this refusal existed, while
+ // the consumer designed remove's `end_state` extension (their
+ // ADR-0129): a remove plan carrying all five bundle names answered
+ // `planned, valid: true` with the digests echoed, and the apply then
+ // removed everything with the bundle bytes untouched -- accept and
+ // ignore, exit 0 twice, even for a 20-byte dummy ZIP. A plan that
+ // echoes inputs apply will never read lies about what approving it
+ // means, and the consumer's rollout story for `end_state` assumed a
+ // loud refusal that did not exist. This is that refusal. When remove
+ // learns to read a bundle (kit 0.2.8+), it narrows rather than lifts:
+ // the operations that read none keep refusing.
+ let target = seeded("bundle-on-remove");
+ let (bytes, bundle_digest, artifact) = bundle_bytes(&[("AGENTS.md", "x\n", 0o644)]);
+ let artifact_path = target.join("..").join("bundle-on-remove.zip");
+ fs::write(&artifact_path, &bytes).unwrap();
+
+ let mut plan_args = vec![
+ "--operation".to_owned(),
+ "remove".to_owned(),
+ "--provider-release-digest".to_owned(),
+ RELEASE.to_owned(),
+ "--operation-id".to_owned(),
+ "operation_01NOBUNDLE".to_owned(),
+ "--expires-at".to_owned(),
+ far_future().to_owned(),
+ ];
+ plan_args.extend(bundle_flags(
+ &artifact_path,
+ &bundle_digest,
+ &artifact,
+ bytes.len(),
+ ));
+ let borrowed: Vec<&str> = plan_args.iter().map(String::as_str).collect();
+ let error = refuse(args("plan-operation", &target, &borrowed));
+ assert_eq!(error.reason(), Some(WireReason::UnsupportedOperation));
+ assert!(
+ error.detail().contains("reads no bundle"),
+ "{}",
+ error.detail()
+ );
+ }
+
#[test]
fn a_bundle_installs_over_the_wire_and_leaves_unowned_files_alone() {
let target = seeded("bundle-install");
diff --git a/crates/setup-core/src/software.rs b/crates/setup-core/src/software.rs
index 95b5536..5bc2831 100644
--- a/crates/setup-core/src/software.rs
+++ b/crates/setup-core/src/software.rs
@@ -366,6 +366,33 @@ impl Software {
}
}
+ /// The member of **this platform's** artifact, or the hint when the table
+ /// has no row for it.
+ ///
+ /// The hint is the first row of the table, and deriving an exposed name
+ /// from it is correct exactly until one platform's member classifies
+ /// differently from the first row's. Cursor is that shape: Unix members
+ /// carry no extension, the Windows member is a `.cmd` — so a plan built
+ /// from the hint promised `bin/agent` while the apply, which resolves this
+ /// host's artifact, wrote `bin/agent.cmd`. The consumer's six-leg matrix
+ /// found it on released `0.0.50`, Windows only, both architectures, with
+ /// *"the planned entry point is not on disk"*. Every reader of an exposed
+ /// name goes through here now; the hint remains only as the fallback for
+ /// a platform the vendor never published for, where nothing was ever
+ /// installed under any name.
+ #[must_use]
+ pub fn member_on(&self, os: &str, arch: &str) -> &'static str {
+ self.artifact_for(os, arch)
+ .map_or_else(|_| self.member_hint(), |artifact| artifact.member)
+ }
+
+ /// [`Self::member_on`] for the machine this process runs on.
+ #[must_use]
+ pub fn member_here(&self) -> &'static str {
+ let (os, arch) = crate::platform_of_this_host();
+ self.member_on(os, arch)
+ }
+
/// The artifact for one platform, or the reason there is not one.
///
/// # Errors
@@ -744,7 +771,7 @@ pub fn remove(software: &Software, root: &Path) -> Result {
// update, roll back, take the bad one off -- deleted the command that was
// running the good one and left a complete version tree nothing could start.
let exposed_version =
- Present::under_named(root, software.command, software.member_hint()).exposed;
+ Present::under_named(root, software.command, software.member_here()).exposed;
fs::remove_dir_all(&version_root).map_err(|error| {
Error::new(
@@ -768,7 +795,7 @@ pub fn remove(software: &Software, root: &Path) -> Result {
let exposed = root
.join("bin")
- .join(exposed_name(software.command, software.member_hint()));
+ .join(exposed_name(software.command, software.member_here()));
if exposed.symlink_metadata().is_ok() {
fs::remove_file(&exposed).map_err(|error| {
Error::new(
@@ -851,7 +878,7 @@ fn executable_candidates(
/// 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_named(root, software.command, software.member_hint());
+ let present = Present::under_named(root, software.command, software.member_here());
if !present.versions.iter().any(|found| found == to) {
return Err(Error::new(
ReasonCode::InvalidTarget,
@@ -891,7 +918,7 @@ pub fn rollback(software: &Software, root: &Path, to: &str) -> Result
let exposed = root
.join("bin")
- .join(exposed_name(software.command, software.member_hint()));
+ .join(exposed_name(software.command, software.member_here()));
expose(executable, &exposed, to, software.command)?;
Ok(Installed {
version: to.to_owned(),
@@ -1255,6 +1282,75 @@ mod tests {
}
}
+ /// A cursor-shaped table: Unix members extensionless, the Windows member a
+ /// batch launcher. The one shape in the estate where the first row's member
+ /// and the Windows row's member classify differently.
+ const MIXED_ARTIFACTS: &[Artifact] = &[
+ Artifact {
+ platform: "linux/arm64",
+ url: "https://example.invalid/linux-arm64.tgz",
+ bytes: 0,
+ sha256: "sha256:0",
+ shape: Shape::GzipTar,
+ member: "dist-package/cursor-agent",
+ },
+ Artifact {
+ platform: "windows/x86_64",
+ url: "https://example.invalid/windows-x86_64.zip",
+ bytes: 0,
+ sha256: "sha256:0",
+ shape: Shape::Zip,
+ member: "dist-package/cursor-agent.cmd",
+ },
+ ];
+
+ /// The name a plan states on Windows is the name Windows actually gets.
+ ///
+ /// Found by the consumer's six-leg matrix on released `0.0.50`, cursor,
+ /// both Windows architectures: `software_install` answered success and
+ /// their postcondition read *"the planned entry point is not on disk"*.
+ /// The plan derived its entry point from `member_hint()` — the **first**
+ /// artifact in the table, a Unix member with no extension, so
+ /// `exposed_name_on(.., windows)` classified it `Native` and the plan
+ /// promised `bin/agent`; the apply resolved this host's artifact, whose
+ /// member is a `.cmd`, and wrote `bin/agent.cmd`. Four Unix legs agreed
+ /// with themselves; the one platform where the two derivations differ is
+ /// the one the plan lied on. Our own evidence stayed green because its
+ /// readback derives the name the same way the apply does — self-consistent
+ /// is not correct.
+ #[test]
+ fn the_windows_entry_point_names_the_member_windows_actually_gets() {
+ let sw = Software {
+ command: "agent",
+ delivery: Delivery::Artifacts(MIXED_ARTIFACTS),
+ ..software()
+ };
+ // The derivation the plan uses for this platform.
+ let planned = exposed_name_on(sw.command, sw.member_on("windows", "x86_64"), true);
+ // The derivation the apply uses: this platform's artifact, directly.
+ let written = exposed_name_on(
+ sw.command,
+ sw.artifact_for("windows", "x86_64").unwrap().member,
+ true,
+ );
+ assert_eq!(planned, written, "the plan and the apply name one file");
+ assert_eq!(planned, "agent.cmd");
+ // The control: the first-row derivation this replaced answers wrongly
+ // on exactly this shape, which is what made the defect Windows-only
+ // and cursor-only.
+ assert_eq!(exposed_name_on(sw.command, sw.member_hint(), true), "agent");
+ }
+
+ /// A platform the table does not carry falls back to the hint.
+ ///
+ /// `software_remove` plans on hosts the vendor never published for, and a
+ /// name that errors there would refuse a removal of nothing.
+ #[test]
+ fn a_platform_without_an_artifact_falls_back_to_the_hint() {
+ let sw = software();
+ assert_eq!(sw.member_on("windows", "x86_64"), sw.member_hint());
+ }
+
/// A build that has never been bumped names exactly one version.
///
/// The absence is the point: `software_update` has nothing to move from
diff --git a/install.ps1 b/install.ps1
index bdbbe09..e4b379f 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.51",
+ [string]$Version = "0.0.52",
[string]$InstallDir = "$env:LOCALAPPDATA\Programs\opencode-setup-system"
)
$ErrorActionPreference = "Stop"
diff --git a/install.sh b/install.sh
index 0df6243..491fee4 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.51}"
+VERSION="${1:-0.0.52}"
PREFIX="${OPENCODE_INSTALL_DIR:-$HOME/.local/bin}"
case "$(uname -s)" in