Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<dir> --json
```

Expand Down
20 changes: 13 additions & 7 deletions crates/harness-runtime/src/software.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,18 +149,25 @@ 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);

// 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_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![
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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| {
Expand Down
68 changes: 68 additions & 0 deletions crates/harness-runtime/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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");
Expand Down
104 changes: 100 additions & 4 deletions crates/setup-core/src/software.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -744,7 +771,7 @@ pub fn remove(software: &Software, root: &Path) -> Result<bool> {
// 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(
Expand All @@ -768,7 +795,7 @@ pub fn remove(software: &Software, root: &Path) -> Result<bool> {

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(
Expand Down Expand Up @@ -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<Installed> {
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,
Expand Down Expand Up @@ -891,7 +918,7 @@ pub fn rollback(software: &Software, root: &Path, to: &str) -> Result<Installed>

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(),
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading