diff --git a/CHANGELOG.md b/CHANGELOG.md
index cdfe7b6..6c8585e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,8 +6,35 @@ The project follows Semantic Versioning. `0.0.x` says plainly that the wire
surface is proven against the consumer's conformance but has not yet been run
against a real installation on every platform it claims.
+An entry is never edited after its release. It says what that release was,
+including claims a later release made false.
+
## [Unreleased]
+## [0.0.2] - 2026-08-25
+
+Installs OpenCode itself, not only its configuration.
+
+- `software_install`, `software_update` and `software_remove`, in the shape
+ agreed with the consumer on `ai-engineers-guild/ai_stp#414`: `--target` is
+ the configuration directory and `--prefix` is the program directory, the plan
+ carries an array `software_artifacts`, and `apply` receives one repeated
+ `--software-artifact` per element in the plan's order.
+- The provider never opens a socket. The contract gives software a download
+ phase and gives a provider no command to run it in, so `plan` names one url,
+ one length and one digest while offline, whoever holds the network fetches
+ exactly that, and `apply` re-checks it offline and installs.
+- Software lands under `--prefix`, never the configuration target, and spends
+ no backup slot: there are ten, and they hold configuration.
+- Reads the one archive shape every vendor ships -- a gzip-compressed tar, or
+ plain bytes. POSIX `ustar` and GNU tar with long-name headers; regular files
+ and directories only. Every other entry type is refused by name.
+- The vendored provider kit moves to 0.2.1. A permission profile this build
+ never advertised now answers `unsupported_permission_profile` instead of the
+ nearest thing the previous closed set had.
+
+Linux, macOS and Windows; x86_64 and arm64.
+
## [0.0.1] - 2026-08-24
First release. Installs, reselects, restores and removes a complete
diff --git a/Cargo.lock b/Cargo.lock
index 95fd472..aed3d9c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -66,7 +66,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "harness-runtime"
-version = "0.0.1"
+version = "0.0.2"
dependencies = [
"provider-v3",
"serde",
@@ -128,7 +128,7 @@ dependencies = [
[[package]]
name = "opencode-setup-system"
-version = "0.0.1"
+version = "0.0.2"
dependencies = [
"harness-runtime",
"provider-v3",
@@ -147,7 +147,7 @@ dependencies = [
[[package]]
name = "provider-v3"
-version = "0.0.1"
+version = "0.0.2"
dependencies = [
"serde",
"serde_json",
@@ -209,7 +209,7 @@ dependencies = [
[[package]]
name = "setup-core"
-version = "0.0.1"
+version = "0.0.2"
dependencies = [
"miniz_oxide",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index 686f535..48ab424 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,7 +8,7 @@ members = [
]
[workspace.package]
-version = "0.0.1"
+version = "0.0.2"
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.1" }
-provider-v3 = { path = "crates/provider-v3", version = "0.0.1" }
-harness-runtime = { path = "crates/harness-runtime", version = "0.0.1" }
+setup-core = { path = "crates/setup-core", version = "0.0.2" }
+provider-v3 = { path = "crates/provider-v3", version = "0.0.2" }
+harness-runtime = { path = "crates/harness-runtime", version = "0.0.2" }
[workspace.lints.rust]
unsafe_code = "forbid"
diff --git a/README.md b/README.md
index c7de08c..cd473f5 100644
--- a/README.md
+++ b/README.md
@@ -7,10 +7,15 @@ the whole configuration — not a pointer to somewhere the content really lives.
That is what makes restore mean something: it returns the instructions, skills,
agents, commands, hooks, MCP entries and settings together, in one step.
-> **Status: complete for the five core operations.** `install`, `replace`,
-> `backup`, `restore` and `remove` all work, over the wire and from the local
-> catalog. The software lifecycle and `launch` are optional in the contract and
-> are not declared, because this build does not perform them.
+> **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.
+>
+> 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.
## Using it
@@ -142,7 +147,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.1 \
+ ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.2 \
status --target /config/
--json
```
diff --git a/SUPPORT.md b/SUPPORT.md
index 679071a..561edb5 100644
--- a/SUPPORT.md
+++ b/SUPPORT.md
@@ -20,11 +20,14 @@ slot holds whatever the target held when it was captured.
## What is not supported
-The software lifecycle — installing, updating or removing the product itself —
-and `launch` are optional in the provider contract and are not declared here,
-because this build does not perform them. A provider that advertised them would
-let a caller ask for something that cannot be honoured, which is worse than not
-offering them.
+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
+for something that cannot be honoured, which is worse than not offering it.
All five core operations do work: `backup`, `restore`, `remove`, `install` and
`replace`, both from the local setup catalog and from an `ai-stp-bundle/1`
diff --git a/crates/setup-core/src/archive.rs b/crates/setup-core/src/archive.rs
index dbd896c..04a0fb0 100644
--- a/crates/setup-core/src/archive.rs
+++ b/crates/setup-core/src/archive.rs
@@ -77,10 +77,25 @@ fn refuse(detail: impl Into) -> Error {
Error::new(ReasonCode::IntegrityMismatch, detail)
}
+/// A failure that came from the *destination*: a disk, a permission, a path.
+///
+/// The caller's own machine could not do what was asked, which is a different
+/// problem from bytes that are not a well-formed archive.
fn from_io(detail: &str, error: io::Error) -> Error {
Error::new(ReasonCode::StateUnavailable, format!("{detail}: {error}")).with_source(error)
}
+/// A failure that came from the *source*: the bytes are not what they claim.
+///
+/// A malformed DEFLATE stream or a truncated tar arrives as an `io::Error`,
+/// because that is how a reader reports it -- but reporting it as
+/// `state_unavailable` would send whoever reads the refusal to look at their
+/// disk instead of at the archive they were handed. A robustness pass over 256
+/// corrupted archives found 163 of them answering that way.
+fn from_source_io(detail: &str, error: io::Error) -> Error {
+ Error::new(ReasonCode::IntegrityMismatch, format!("{detail}: {error}")).with_source(error)
+}
+
/// A gzip member, inflated as it is read.
///
/// Only the framing is written here. The DEFLATE stream inside it is decoded by
@@ -109,7 +124,7 @@ impl Gunzip {
let mut head = [0_u8; 10];
inner
.read_exact(&mut head)
- .map_err(|error| from_io("gzip header could not be read", error))?;
+ .map_err(|error| from_source_io("gzip header could not be read", error))?;
if head[0] != 0x1F || head[1] != 0x8B {
return Err(refuse(format!(
"not a gzip member: magic {:#04x}{:02x}",
@@ -131,20 +146,20 @@ impl Gunzip {
}
if flags & 0b0000_0100 != 0 {
let mut length = [0_u8; 2];
- inner
- .read_exact(&mut length)
- .map_err(|error| from_io("gzip extra field length could not be read", error))?;
+ inner.read_exact(&mut length).map_err(|error| {
+ from_source_io("gzip extra field length could not be read", error)
+ })?;
let mut extra = vec![0_u8; usize::from(u16::from_le_bytes(length))];
inner
.read_exact(&mut extra)
- .map_err(|error| from_io("gzip extra field could not be read", error))?;
+ .map_err(|error| from_source_io("gzip extra field could not be read", error))?;
}
for (bit, what) in [(0b0000_1000_u8, "name"), (0b0001_0000, "comment")] {
if flags & bit != 0 {
let mut byte = [0_u8; 1];
loop {
inner.read_exact(&mut byte).map_err(|error| {
- from_io(&format!("gzip {what} field could not be read"), error)
+ from_source_io(&format!("gzip {what} field could not be read"), error)
})?;
if byte[0] == 0 {
break;
@@ -156,7 +171,7 @@ impl Gunzip {
let mut check = [0_u8; 2];
inner
.read_exact(&mut check)
- .map_err(|error| from_io("gzip header checksum could not be read", error))?;
+ .map_err(|error| from_source_io("gzip header checksum could not be read", error))?;
}
Ok(Self {
@@ -389,7 +404,7 @@ impl Tar {
let read = self
.inner
.read(&mut self.buffer[..want])
- .map_err(|error| from_io("archive content could not be read", error))?;
+ .map_err(|error| from_source_io("archive content could not be read", error))?;
if read == 0 {
return Err(refuse("archive ended in the middle of an entry"));
}
@@ -418,7 +433,7 @@ impl Tar {
self.padding = 0;
self.inner
.read_exact(&mut waste[..take])
- .map_err(|error| from_io("archive padding could not be read", error))
+ .map_err(|error| from_source_io("archive padding could not be read", error))
}
/// Read one 512-byte block, returning `None` at the end-of-archive marker.
@@ -429,7 +444,7 @@ impl Tar {
let read = self
.inner
.read(&mut block[have..])
- .map_err(|error| from_io("archive header could not be read", error))?;
+ .map_err(|error| from_source_io("archive header could not be read", error))?;
if read == 0 {
if have == 0 {
return Ok(None);
@@ -1136,6 +1151,110 @@ mod tests {
fs::remove_dir_all(&into).unwrap();
}
+ /// A deterministic mutator, so a failure is reproducible from the seed.
+ ///
+ /// Not a random number generator worth the name -- an LCG with the
+ /// constants from Numerical Recipes. It only has to visit a lot of
+ /// different byte patterns in the same order every time.
+ fn scramble(seed: u64, bytes: &mut [u8], edits: usize) {
+ let mut state = seed;
+ for _ in 0..edits {
+ state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
+ // Taking bytes out of the state rather than casting it: the
+ // truncation is the point, and saying so with `to_le_bytes` needs
+ // no exception from the lint that would otherwise object.
+ let octets = state.to_le_bytes();
+ let at = usize::from(u16::from_le_bytes([octets[2], octets[3]])) % bytes.len();
+ state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
+ bytes[at] = state.to_le_bytes()[3];
+ }
+ }
+
+ #[test]
+ fn arbitrary_bytes_are_refused_and_never_panic() {
+ // This reader is handed whatever a vendor's CDN returned, before any
+ // digest has been checked -- `apply` verifies the artifact, but the
+ // gzip and tar framing is parsed to *get* to the bytes a digest covers.
+ // So every malformed input has to be a refusal, not an abort, and not a
+ // write outside the destination.
+ //
+ // A real fuzzer would be better. This is the shape of one that runs in
+ // the ordinary test suite: a known-good archive, corrupted in a
+ // reproducible sequence, plus inputs that were never an archive at all.
+ let good = gzip_tar(
+ &[
+ Item::directory("package"),
+ Item::file("package/program", &[9_u8; 3000], 0o755),
+ Item::file("package/notes.md", b"read me", 0o644),
+ ],
+ Dialect::Gnu,
+ );
+
+ let into = scratch("arbitrary");
+ for seed in 0..256_u64 {
+ let mut corrupted = good.clone();
+ let edits = 1 + usize::try_from(seed % 12).unwrap_or(0);
+ scramble(seed, &mut corrupted, edits);
+ // Whatever it decides, it must decide it: no panic, no hang, and
+ // nothing left outside the directory it was given. And when it
+ // refuses, it must say the archive is wrong -- not that this
+ // machine's state is unavailable, which would send whoever reads
+ // the refusal to look at their disk. All 256 answered that way once.
+ match extract_gzip_tar(corrupted.as_slice(), &into, ROOMY) {
+ Ok(_) => {}
+ Err(error) => assert_eq!(
+ error.reason(),
+ ReasonCode::IntegrityMismatch,
+ "seed {seed} blamed the wrong side: {error}"
+ ),
+ }
+ assert!(
+ !into.join("..").join("escaped").exists(),
+ "seed {seed} wrote outside the destination"
+ );
+ }
+
+ // Inputs that are not archives at all, including ones whose first bytes
+ // look like one.
+ for (label, bytes) in [
+ ("empty", [].as_slice()),
+ ("one byte", b"\x1f".as_slice()),
+ ("gzip magic only", b"\x1f\x8b".as_slice()),
+ (
+ "gzip header, no body",
+ b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff".as_slice(),
+ ),
+ ("zip", b"PK\x03\x04\x14\x00\x00\x00".as_slice()),
+ (
+ "text",
+ b"this is not an archive, it is a sentence".as_slice(),
+ ),
+ ] {
+ let outcome = extract_gzip_tar(bytes, &into, ROOMY);
+ assert!(outcome.is_err(), "{label} was accepted as an archive");
+ }
+
+ let _ = fs::remove_dir_all(&into);
+ }
+
+ #[test]
+ fn a_declared_size_larger_than_the_archive_is_refused_not_trusted() {
+ // The header says how long the entry is. A reader that believed it
+ // would read past the end of what it was given.
+ let mut raw = tar(&[Item::file("payload", b"short", 0o644)], Dialect::Posix);
+ raw[124..136].copy_from_slice(b"77777777777\0");
+ for byte in &mut raw[148..156] {
+ *byte = b' ';
+ }
+ let sum: u64 = raw[..BLOCK].iter().map(|byte| u64::from(*byte)).sum();
+ raw[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
+
+ let into = scratch("bigsize");
+ let error = extract_gzip_tar(gzip(&raw).as_slice(), &into, ROOMY).unwrap_err();
+ assert_eq!(error.reason(), ReasonCode::IntegrityMismatch);
+ let _ = fs::remove_dir_all(&into);
+ }
+
#[test]
fn a_header_whose_checksum_does_not_match_is_refused() {
let mut raw = tar(&[Item::file("payload", b"x", 0o644)], Dialect::Posix);
diff --git a/install.ps1 b/install.ps1
index 7e6dba3..bfeb4fb 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.1",
+ [string]$Version = "0.0.2",
[string]$InstallDir = "$env:LOCALAPPDATA\Programs\opencode-setup-system"
)
$ErrorActionPreference = "Stop"
diff --git a/install.sh b/install.sh
index 6e0e2f8..91670f9 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.1}"
+VERSION="${1:-0.0.2}"
PREFIX="${OPENCODE_INSTALL_DIR:-$HOME/.local/bin}"
case "$(uname -s)" in