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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.1"
version = "0.0.2"
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.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"
Expand Down
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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/<dir> --json
```

Expand Down
13 changes: 8 additions & 5 deletions SUPPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
139 changes: 129 additions & 10 deletions crates/setup-core/src/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,25 @@ fn refuse(detail: impl Into<String>) -> 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
Expand Down Expand Up @@ -109,7 +124,7 @@ impl<R: Read> Gunzip<R> {
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}",
Expand All @@ -131,20 +146,20 @@ impl<R: Read> Gunzip<R> {
}
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;
Expand All @@ -156,7 +171,7 @@ impl<R: Read> Gunzip<R> {
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 {
Expand Down Expand Up @@ -389,7 +404,7 @@ impl<R: Read> Tar<R> {
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"));
}
Expand Down Expand Up @@ -418,7 +433,7 @@ impl<R: Read> Tar<R> {
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.
Expand All @@ -429,7 +444,7 @@ impl<R: Read> Tar<R> {
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);
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading