From f7c977d6f2b77ed8d886aa92219ca1ce0aab4ac6 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Thu, 3 Sep 2026 22:31:12 +0500 Subject: [PATCH] feat: a release rendered from the monorepo Generated by the setup-systems release publisher at 0.0.60. This repository is generated: propose changes through its public issues and pull requests. --- .github/workflows/publish-crates.yml | 48 +++ crates/harness-runtime/src/facts.rs | 2 +- crates/harness-runtime/src/wire.rs | 238 +++++++++++-- crates/opencode-setup-system/src/software.rs | 78 ++--- crates/provider-v3/src/bundle.rs | 335 ++++++++++++++++++- crates/provider-v3/src/reason.rs | 8 + references/opencode-baseline.json | 88 ++--- tools/build_crates_io.py | 213 ++++++++++++ 8 files changed, 896 insertions(+), 114 deletions(-) create mode 100644 .github/workflows/publish-crates.yml create mode 100644 tools/build_crates_io.py diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml new file mode 100644 index 0000000..3cbc36a --- /dev/null +++ b/.github/workflows/publish-crates.yml @@ -0,0 +1,48 @@ +name: publish-crates + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + +concurrency: + group: publish-crates-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + publish: + name: package, verify and publish + runs-on: ubuntu-latest + environment: release + permissions: + contents: read + id-token: write # crates.io exchanges this OIDC identity for one short-lived publish token + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Build the self-contained crate + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + version="${TAG#v}" + test "$version" = "$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)" + python3 tools/build_crates_io.py --out crates-io --harness opencode --version "$version" + cargo package --manifest-path crates-io/opencode-setup-system/Cargo.toml + cargo install --path crates-io/opencode-setup-system --root crates-io/install --locked + crates-io/install/bin/opencode-setup-system provider-info > crates-io/provider-info.json + test "$(jq -r .provider_id crates-io/provider-info.json)" = "opencode-setup-system" + + - name: Mint a short-lived crates.io token + id: crates + uses: rust-lang/crates-io-auth-action@82864bf380abd3e242c564a5ba58dd29d6265b00 + + - name: Publish opencode-setup-system + env: + CARGO_REGISTRY_TOKEN: ${{ steps.crates.outputs.token }} + run: cargo publish --manifest-path crates-io/opencode-setup-system/Cargo.toml diff --git a/crates/harness-runtime/src/facts.rs b/crates/harness-runtime/src/facts.rs index 77ed989..a4f0858 100644 --- a/crates/harness-runtime/src/facts.rs +++ b/crates/harness-runtime/src/facts.rs @@ -316,7 +316,7 @@ pub struct Foreign { pub const BACKUP_SLOTS: usize = 10; /// The bundle format every setup system reads. -pub const BUNDLE_FORMAT: &str = "ai-stp-bundle/1"; +pub const BUNDLE_FORMAT: &str = "ai-stp-bundle/2"; impl Harness { /// Whether one relative path falls inside a namespace this harness claims. diff --git a/crates/harness-runtime/src/wire.rs b/crates/harness-runtime/src/wire.rs index 85ae571..fd04a0b 100644 --- a/crates/harness-runtime/src/wire.rs +++ b/crates/harness-runtime/src/wire.rs @@ -105,6 +105,38 @@ fn verified_bundle(harness: &Harness, bundle: &ArgvBundle, surface: Surface) -> harness_id: harness.harness_id, }, )?; + if verified.manifest.bundle_format == provider_v3::bundle::BUNDLE_FORMAT { + let bound_scope = verified + .manifest + .projection_profile + .as_ref() + .map(|profile| profile.target_scope.as_str()) + .ok_or_else(|| { + Error::refuse( + WireReason::AdaptationBindingMissing, + "bundle v2 has no projection_profile", + ) + })?; + let bound_scope = match bound_scope { + "global" => None, + value => Some(provider_v3::TargetScope::parse(value).ok_or_else(|| { + Error::refuse( + WireReason::ProjectionProfileMismatch, + format!("bundle v2 names unknown target scope {value:?}"), + ) + })?), + }; + if let Surface::At(requested) = surface + && requested != bound_scope + { + return Err(Error::refuse( + WireReason::ProjectionProfileMismatch, + "bundle v2's bound scope differs from the requested target scope", + )); + } + let profile = harness.projection_profile_for(bound_scope)?; + verified.require_projection_profile(&profile)?; + } check_within_surface(harness, verified.files.keys(), surface)?; check_declared_kinds(harness, &verified, surface)?; Ok(verified) @@ -834,7 +866,11 @@ fn plan(harness: &Harness, target: &Path, request: &PlanRequest) -> Result { @@ -1209,6 +1245,70 @@ fn check_survivors(planned: &[EndState], ready: &Bundle) -> Result<()> { Ok(()) } +/// The target identity a selected backup will produce under this scope. +/// +/// A slot payload is a transport tree, not always the target's identity tree. +/// Under a shared scope it contains parent directories needed to carry the +/// recorded files, while status hashes the recorded file inventory itself. +/// Hashing the payload wholesale therefore added directory entries that +/// restored status correctly omitted. +fn restore_target_identity( + harness: &Harness, + payload: &Path, + scope: Option, +) -> Result { + let owned = if harness.scoped_for(scope).is_some() { + files_in_payload(payload)? + } else { + harness + .owned_projection(scope) + .iter() + .map(|path| (*path).to_owned()) + .collect() + }; + Ok(setup_core::digest::of_owned( + payload, + &as_paths(&owned), + &harness.not_our_identity(), + )?) +} + +/// Every regular payload file, relative to the payload root. +fn files_in_payload(payload: &Path) -> Result> { + let mut found = Vec::new(); + for entry in fs::read_dir(payload).map_err(|error| { + setup_core::Error::new( + setup_core::ReasonCode::StateUnavailable, + format!("cannot list backup payload {}", payload.display()), + ) + .with_source(error) + })? { + let entry = entry.map_err(|error| { + setup_core::Error::new( + setup_core::ReasonCode::StateUnavailable, + format!( + "cannot read an entry of backup payload {}", + payload.display() + ), + ) + .with_source(error) + })?; + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + return Err(Error::from(setup_core::Error::new( + setup_core::ReasonCode::StateUnavailable, + "a backup payload entry has an unrepresentable name", + ))); + }; + if entry.path().is_dir() { + found.extend(files_under(&entry.path(), &name)?); + } else { + found.push(name); + } + } + found.sort(); + Ok(found) +} + /// The backup a restore names, or the newest when it names none. pub(crate) fn chosen_backup(pool: &Pool, requested: Option<&str>) -> Result { match requested { @@ -1884,7 +1984,14 @@ fn replace_recorded_from( let destination = target.root().join(&name); if source.is_dir() { setup_core::backup::copy_tree(&source, &destination, &[])?; - written.extend(files_under(&destination, &name)?); + // The destination is deliberately a merge: it may contain files + // another provider or the person owns. Reading it back here made + // those neighbours part of this provider's `written_paths`, so + // status widened after a restore and no longer matched the exact + // BackupRef identity promised by the plan. The slot payload is the + // complete record of what this provider restored; derive the + // inventory from it and only it. + written.extend(files_under(&source, &name)?); } else { let bytes = fs::read(&source).map_err(|error| { setup_core::Error::new( @@ -3142,6 +3249,10 @@ mod tests { .unwrap(); let planned = scoped_plan(&target, "restore", "operation_01SCOPEDR"); + let promised = planned["plan"]["restore_target_digest"] + .as_str() + .unwrap() + .to_owned(); let done = scoped_apply(&target, &planned, "res-restore"); assert_eq!(done["state"], "verified", "{done}"); assert_eq!( @@ -3154,6 +3265,11 @@ mod tests { "after\n", "the restore reverted a file this provider never wrote" ); + let status = run(args("status", &target, &["--target-scope", "user_root"])); + assert_eq!( + status["target_digest"], promised, + "restore produced bytes different from its BackupRef-bound promise" + ); } /// A backup writes nothing, so it must not erase the record of what was @@ -3270,7 +3386,12 @@ mod tests { /// a global one, and the first version of these three could not. fn install_scoped(target: &Path, tag: &str, name: &str, body: &str) { let relative = format!("shared/{name}/SKILL.md"); - let (bytes, bundle_digest, artifact) = bundle_bytes(&[(&relative, body, 0o644)]); + let (bytes, bundle_digest, artifact) = bundle_bytes_for( + &TEST, + Some(provider_v3::TargetScope::UserRoot), + &[(&relative, body, 0o644)], + Some("skill"), + ); let artifact_path = target.join("..").join(format!("scoped-{tag}.zip")); fs::write(&artifact_path, &bytes).unwrap(); let flags = bundle_flags(&artifact_path, &bundle_digest, &artifact, bytes.len()); @@ -3405,7 +3526,7 @@ mod tests { &flags.iter().map(String::as_str).collect::>(), )); assert_eq!(answer["rejected"], true, "{answer}"); - assert_eq!(answer["reason"], "unsupported_component_kind"); + assert_eq!(answer["reason"], "adaptation_binding_mismatch"); // A kind this harness does implement still passes. let (bytes, digest, artifact) = @@ -3876,9 +3997,9 @@ mod tests { /// asserts the current behaviour and either closes the issue with evidence /// or names the field still missing. #[test] - fn an_empty_setup_version_records_everything_a_populated_one_does() { + fn a_zero_byte_setup_version_records_everything_a_populated_one_does() { let target = seeded("empty-bundle"); - let (bytes, bundle_digest, artifact) = bundle_bytes(&[]); + let (bytes, bundle_digest, artifact) = bundle_bytes(&[("AGENTS.md", "", 0o644)]); let artifact_path = target.parent().unwrap().join("empty.zip"); fs::write(&artifact_path, &bytes).unwrap(); let flags = bundle_flags(&artifact_path, &bundle_digest, &artifact, bytes.len()); @@ -4853,10 +4974,28 @@ mod tests { fn bundle_bytes_declaring( files: &[(&str, &str, u32)], kind: Option<&str>, + ) -> (Vec, String, String) { + bundle_bytes_for(&TEST, None, files, kind) + } + + /// A v2 bundle bound to the exact profile selected for one test scope. + #[allow( + clippy::too_many_lines, + reason = "the fixture names every canonical v2 manifest and ZIP member in one place" + )] + fn bundle_bytes_for( + harness: &Harness, + scope: Option, + files: &[(&str, &str, u32)], + kind: Option<&str>, ) -> (Vec, String, String) { use provider_v3::bundle::{BUNDLE_DOMAIN, FILES_PREFIX, MANIFEST_MEMBER, REQUIRED_MEMBERS}; use provider_v3::zip::build::{Entry, write}; + let owner = "component_00000000000000000000000000"; + let mut member_paths = files.iter().map(|(path, _, _)| *path).collect::>(); + member_paths.sort_unstable(); + let profile = harness.projection_profile_for(scope).unwrap(); let records: Vec = files .iter() .map(|(path, body, mode)| { @@ -4866,17 +5005,35 @@ mod tests { "digest": setup_core::digest::of_bytes(body.as_bytes()), "byte_length": body.len(), "mode": mode, - "owner": "", + "owner": owner, }) }) .collect(); let mut manifest = serde_json::json!({ "schema_version": 1, - "bundle_format": "ai-stp-bundle/1", + "bundle_format": provider_v3::bundle::BUNDLE_FORMAT, "protocol_version": provider_v3::bundle::BUNDLE_PROTOCOL_VERSION, - "harness_id": TEST.harness_id, + "harness_id": harness.harness_id, "builder_version": "0.1.0", "input_digest": "sha256:".to_owned() + &"3".repeat(64), + "projection_profile": { + "profile_id": profile.profile_id, + "profile_digest": profile.digest, + "target_scope": scope.map_or("global", provider_v3::TargetScope::as_str), + }, + "component_adaptations": [{ + "stable_id": owner, + "version": "1.0", + "passport_digest": "sha256:".to_owned() + &"1".repeat(64), + "adaptation_id": "adaptation_".to_owned() + &"2".repeat(64), + "projection_artifact": { + "digest": "sha256:".to_owned() + &"3".repeat(64), + "size_bytes": 128, + }, + "provider_component_kind": kind.unwrap_or("instruction"), + "projection_kind": "native_files", + "member_paths": member_paths, + }], "managed_paths": files.iter().map(|(path, _, _)| *path).collect::>(), "files": records, "limits": { @@ -4885,11 +5042,14 @@ mod tests { "max_bundle_bytes": 64 * 1024 * 1024, }, }); + if let Some(scope) = scope { + manifest["target_scope"] = serde_json::json!(scope.as_str()); + } if let Some(kind) = kind { manifest["conversion_report"] = serde_json::json!({ "complete": true, "entries": [{ - "stable_id": "component_00000000000000000000000000", + "stable_id": owner, "component_type": kind, "native_surface": files.first().map_or("", |(path, _, _)| path), "state": "complete", @@ -4914,7 +5074,7 @@ mod tests { serde_json::to_vec(&serde_json::json!({ "stable_id": "setup_00000000000000000000000000", "version": "3.1.0", - "harness_id": TEST.harness_id, + "harness_id": harness.harness_id, })) .unwrap() } else { @@ -4943,7 +5103,7 @@ mod tests { "--bundle".to_owned(), path.to_string_lossy().into_owned(), "--bundle-format".to_owned(), - "ai-stp-bundle/1".to_owned(), + provider_v3::bundle::BUNDLE_FORMAT.to_owned(), "--bundle-digest".to_owned(), bundle_digest.to_owned(), "--artifact-digest".to_owned(), @@ -5147,7 +5307,17 @@ mod tests { files: &[(&str, &str, u32)], scope: Option<&str>, ) -> (serde_json::Value, Vec) { - let (bytes, bundle_digest, artifact) = bundle_bytes(files); + let target_scope = scope.and_then(provider_v3::TargetScope::parse); + let (bytes, bundle_digest, artifact) = bundle_bytes_for( + &TEST, + target_scope, + files, + Some(if target_scope.is_some() { + "skill" + } else { + "setting" + }), + ); let artifact_path = target.join("..").join(format!("keep-{tag}.zip")); fs::write(&artifact_path, &bytes).unwrap(); let flags = bundle_flags(&artifact_path, &bundle_digest, &artifact, bytes.len()); @@ -5418,8 +5588,12 @@ mod tests { fs::write(workspace.join("README.md"), "# theirs\n").unwrap(); let before = run_for(&harness, args("status", &workspace, &[])); - let (bytes, bundle_digest, artifact) = - bundle_bytes(&[(".cursor/skills/probe/SKILL.md", "probe\n", 0o644)]); + let (bytes, bundle_digest, artifact) = bundle_bytes_for( + &harness, + Some(provider_v3::TargetScope::Project), + &[(".cursor/skills/probe/SKILL.md", "probe\n", 0o644)], + Some("skill"), + ); let artifact_path = workspace.join("..").join("project.zip"); fs::write(&artifact_path, &bytes).unwrap(); let flags = bundle_flags(&artifact_path, &bundle_digest, &artifact, bytes.len()); @@ -5518,8 +5692,12 @@ mod tests { unasked["target_digest"], asked["target_digest"], "the global set hashes the repository's skills/; the project set has no record and nothing of ours" ); - let (bytes, bundle_digest, artifact) = - bundle_bytes(&[(".cursor/skills/probe/SKILL.md", "probe\n", 0o644)]); + let (bytes, bundle_digest, artifact) = bundle_bytes_for( + &harness, + Some(provider_v3::TargetScope::Project), + &[(".cursor/skills/probe/SKILL.md", "probe\n", 0o644)], + Some("skill"), + ); let artifact_path = workspace.join("..").join("asked.zip"); fs::write(&artifact_path, &bytes).unwrap(); let mut plan_args = vec![ @@ -5567,8 +5745,12 @@ mod tests { let harness = project_shaped(); let workspace = scratch("project-scope-contradiction").join("workspace"); fs::create_dir_all(&workspace).unwrap(); - let (bytes, bundle_digest, artifact) = - bundle_bytes(&[(".cursor/skills/probe/SKILL.md", "probe\n", 0o644)]); + let (bytes, bundle_digest, artifact) = bundle_bytes_for( + &harness, + Some(provider_v3::TargetScope::Project), + &[(".cursor/skills/probe/SKILL.md", "probe\n", 0o644)], + Some("skill"), + ); let artifact_path = workspace.join("..").join("contra.zip"); fs::write(&artifact_path, &bytes).unwrap(); let flags = bundle_flags(&artifact_path, &bundle_digest, &artifact, bytes.len()); @@ -5654,7 +5836,9 @@ mod tests { provider_v3::ComponentKind::Setting, ]; let target = seeded("scoped-only-kind"); - let (bytes, bundle_digest, artifact) = bundle_bytes_declaring( + let (bytes, bundle_digest, artifact) = bundle_bytes_for( + &harness, + Some(provider_v3::TargetScope::UserRoot), &[("shared/probe/SKILL.md", "probe\n", 0o644)], Some("skill"), ); @@ -5707,9 +5891,11 @@ mod tests { )); let borrowed: Vec<&str> = global.iter().map(String::as_str).collect(); let error = refuse_for(&harness, args("plan-operation", &target, &borrowed)); - assert_eq!(error.reason(), Some(WireReason::UnsupportedComponentKind)); + assert_eq!(error.reason(), Some(WireReason::ProjectionProfileMismatch)); assert!( - error.detail().contains("at the global profile"), + error + .detail() + .contains("different provider projection profile"), "{}", error.detail() ); @@ -5964,8 +6150,12 @@ mod tests { #[test] fn a_bundle_routed_to_a_scope_installs_into_that_scopes_namespace() { let target = seeded("bundle-scoped"); - let (bytes, bundle_digest, artifact) = - bundle_bytes(&[("shared/review/SKILL.md", "# review\n", 0o644)]); + let (bytes, bundle_digest, artifact) = bundle_bytes_for( + &TEST, + Some(provider_v3::TargetScope::UserRoot), + &[("shared/review/SKILL.md", "# review\n", 0o644)], + Some("skill"), + ); let artifact_path = target.join("..").join("scoped-bundle.zip"); fs::write(&artifact_path, &bytes).unwrap(); let flags = bundle_flags(&artifact_path, &bundle_digest, &artifact, bytes.len()); diff --git a/crates/opencode-setup-system/src/software.rs b/crates/opencode-setup-system/src/software.rs index f7b3700..2c4b120 100644 --- a/crates/opencode-setup-system/src/software.rs +++ b/crates/opencode-setup-system/src/software.rs @@ -21,103 +21,103 @@ use harness_runtime::{Artifact, Delivery, Previous, Shape, Software}; pub(crate) const ARTIFACTS: &[Artifact] = &[ Artifact { platform: "linux/arm64", - url: "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.26.tgz", - bytes: 59_947_971, - sha256: "sha256:5e0cc6c6c48d6629c8f5d3d5c9f9670e8dac7ba14d295801bb3f6a783a8f841b", + url: "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.27.tgz", + bytes: 59_945_385, + sha256: "sha256:83bf3812ecad71b3a463c5c0a7ceb0dba9db96964f3e7f8ba6bf30ca138287e8", shape: Shape::GzipTar, member: "package/bin/opencode", }, Artifact { platform: "linux/x86_64", - url: "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.26.tgz", - bytes: 60_169_535, - sha256: "sha256:990d8b07111517a78ba779709ff8f438e0dcf2a7fb66d36df7507c8e93358f02", + url: "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.27.tgz", + bytes: 60_168_253, + sha256: "sha256:0aba86ba404f52e57bd154ec3565cd3e86d344743bf32e3004bf7fdbd3363ac4", shape: Shape::GzipTar, member: "package/bin/opencode", }, Artifact { platform: "macos/arm64", - url: "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.26.tgz", - bytes: 45_942_652, - sha256: "sha256:d9c09ba039dd62f983fc66c65777910f20eead2c4e30cbff888f26d640607e15", + url: "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.27.tgz", + bytes: 45_940_410, + sha256: "sha256:dba942c12128491b7c00f5d4b395bb8d36061f293b59db501ca9b0911a701680", shape: Shape::GzipTar, member: "package/bin/opencode", }, Artifact { platform: "macos/x86_64", - url: "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.26.tgz", - bytes: 48_118_308, - sha256: "sha256:dff2571b3ad3f04dff7f0555bf4e679615c1f70afb35258f139d22a491da57e3", + url: "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.27.tgz", + bytes: 48_115_145, + sha256: "sha256:8e379467c2f911d5a6bb14a453b8f760e093daf8c5c6b9ee1da8f3515477e8f2", shape: Shape::GzipTar, member: "package/bin/opencode", }, Artifact { platform: "windows/arm64", - url: "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.26.tgz", - bytes: 58_398_040, - sha256: "sha256:419799338b25d5e62a393136c61166ddf0e78229b784daf0a9fabfb0df66eb9f", + url: "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.27.tgz", + bytes: 58_397_893, + sha256: "sha256:3da5a83466c814922fc1472ef4eef1c37cae990a1cfe1530959c83d3f5b13cda", shape: Shape::GzipTar, member: "package/bin/opencode.exe", }, Artifact { platform: "windows/x86_64", - url: "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.26.tgz", - bytes: 60_082_922, - sha256: "sha256:fca4106836f9ca9d9485d010a247d0d928eecfff972b9019ff522b6ba9885934", + url: "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.27.tgz", + bytes: 60_079_608, + sha256: "sha256:d940ca3115e9a87107bb666c30c3efea88bcad1c2d34212c8deb4401a3054792", shape: Shape::GzipTar, member: "package/bin/opencode.exe", }, ]; -/// The artifacts 1.18.25 was published as, kept so +/// The artifacts 1.18.26 was published as, kept so /// `software_update` has a version to move from and `rollback` a tree to /// return to. Measured from bytes when it was the current pin. pub(crate) const PREVIOUS_ARTIFACTS: &[Artifact] = &[ Artifact { platform: "linux/arm64", - url: "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.25.tgz", - bytes: 59_965_131, - sha256: "sha256:2b14bd75252cbaec62abd5b3df43da01c4ae521a7e62a2f577af7ea0edd7c7a1", + url: "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.26.tgz", + bytes: 59_947_971, + sha256: "sha256:5e0cc6c6c48d6629c8f5d3d5c9f9670e8dac7ba14d295801bb3f6a783a8f841b", shape: Shape::GzipTar, member: "package/bin/opencode", }, Artifact { platform: "linux/x86_64", - url: "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.25.tgz", - bytes: 60_179_907, - sha256: "sha256:3e6d285607b6e9acd1f60ec350cc3954d7351d9dcad970ded390f7b733e34280", + url: "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.26.tgz", + bytes: 60_169_535, + sha256: "sha256:990d8b07111517a78ba779709ff8f438e0dcf2a7fb66d36df7507c8e93358f02", shape: Shape::GzipTar, member: "package/bin/opencode", }, Artifact { platform: "macos/arm64", - url: "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.25.tgz", - bytes: 45_945_992, - sha256: "sha256:5a2ba8cdd01e8d9d3b3658cc8aeec27e22c81414a885bbe05af5958b022581c2", + url: "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.26.tgz", + bytes: 45_942_652, + sha256: "sha256:d9c09ba039dd62f983fc66c65777910f20eead2c4e30cbff888f26d640607e15", shape: Shape::GzipTar, member: "package/bin/opencode", }, Artifact { platform: "macos/x86_64", - url: "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.25.tgz", - bytes: 48_128_085, - sha256: "sha256:f42ee1f37d6dce61501140357cadfc0c153224e1224dd0ef00fbb073ce538abb", + url: "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.26.tgz", + bytes: 48_118_308, + sha256: "sha256:dff2571b3ad3f04dff7f0555bf4e679615c1f70afb35258f139d22a491da57e3", shape: Shape::GzipTar, member: "package/bin/opencode", }, Artifact { platform: "windows/arm64", - url: "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.25.tgz", - bytes: 58_410_963, - sha256: "sha256:33a0d88c0fd16cf93eb6302c2eeefd70c84400bf33c50cc5456993eb43c5cc3a", + url: "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.26.tgz", + bytes: 58_398_040, + sha256: "sha256:419799338b25d5e62a393136c61166ddf0e78229b784daf0a9fabfb0df66eb9f", shape: Shape::GzipTar, member: "package/bin/opencode.exe", }, Artifact { platform: "windows/x86_64", - url: "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.25.tgz", - bytes: 60_101_564, - sha256: "sha256:07bcd049b7f1c7ba7184ab97240fb9cd63332fdbfa1d53d84dfbde0f010f4796", + url: "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.26.tgz", + bytes: 60_082_922, + sha256: "sha256:fca4106836f9ca9d9485d010a247d0d928eecfff972b9019ff522b6ba9885934", shape: Shape::GzipTar, member: "package/bin/opencode.exe", }, @@ -125,12 +125,12 @@ pub(crate) const PREVIOUS_ARTIFACTS: &[Artifact] = &[ /// Opencode's program, and where its bytes come from. pub(crate) const SOFTWARE: Software = Software { - version: "1.18.26", + version: "1.18.27", command: "opencode", delivery: Delivery::Artifacts(ARTIFACTS), unsupported: &[], previous: Some(Previous { - version: "1.18.25", + version: "1.18.26", artifacts: PREVIOUS_ARTIFACTS, }), }; diff --git a/crates/provider-v3/src/bundle.rs b/crates/provider-v3/src/bundle.rs index 7b6ded4..3ed2949 100644 --- a/crates/provider-v3/src/bundle.rs +++ b/crates/provider-v3/src/bundle.rs @@ -27,14 +27,19 @@ use serde::Deserialize; use setup_core::digest; use crate::error::{Error, Result}; +use crate::info::ProjectionProfile; use crate::reason::WireReason; use crate::zip; /// The digest domain for a bundle manifest. pub const BUNDLE_DOMAIN: &str = "ai-stp:bundle:v1"; -/// The format tag this reader accepts. -pub const BUNDLE_FORMAT: &str = "ai-stp-bundle/1"; +/// The original format tag, kept byte-identical during the v2 rollout. +pub const BUNDLE_FORMAT: &str = "ai-stp-bundle/2"; + +/// The adaptation-bound format. +#[cfg(test)] +const RETIRED_BUNDLE_FORMAT_V1: &str = "ai-stp-bundle/1"; /// The protocol version a bundle manifest declares. /// @@ -174,6 +179,48 @@ pub struct Limits { pub max_bundle_bytes: Option, } +/// The exact provider profile selected by the v2 compiler. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct ProjectionProfileBinding { + /// Stable profile identifier. + pub profile_id: String, + /// Content-derived identity of the provider declaration. + pub profile_digest: String, + /// Resolved bundle target scope. + pub target_scope: String, +} + +/// One immutable projection artifact named by a component adaptation. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct ProjectionArtifactBinding { + /// Domain-separated digest from the component passport. + pub digest: String, + /// Exact projection archive byte length. + pub size_bytes: u64, +} + +/// One component version's exact adaptation atom in a v2 bundle. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct ComponentAdaptationBinding { + /// Stable component identity. + pub stable_id: String, + /// Exact component version. + pub version: String, + /// Exact component-version passport digest. + pub passport_digest: String, + /// Content-derived adaptation identity. + pub adaptation_id: String, + /// Exact immutable projection artifact. + pub projection_artifact: ProjectionArtifactBinding, + /// Provider-native component vocabulary member. + pub provider_component_kind: String, + /// Projection vocabulary member selected by the adaptation. + pub projection_kind: String, + /// Sorted exact paths the adaptation artifact declares. + #[serde(default)] + pub member_paths: Vec, +} + /// The `bundle.json` a compiler writes into the archive. /// /// This is *not* the compiler's own result object. `cli-harness-bundle.schema.json` @@ -190,6 +237,15 @@ pub struct Manifest { pub protocol_version: u32, /// The harness this bundle configures. pub harness_id: String, + /// The resolved non-global scope. Absent means `global`. + #[serde(default)] + pub target_scope: Option, + /// Exact profile binding required by bundle v2. + #[serde(default)] + pub projection_profile: Option, + /// Sorted component adaptation bindings required by bundle v2. + #[serde(default)] + pub component_adaptations: Vec, /// What compiled it. #[serde(default)] pub builder_version: String, @@ -306,7 +362,7 @@ impl Bundle { if claim.bundle_format != BUNDLE_FORMAT { return Err(Error::refuse( WireReason::UnsupportedBundleFormat, - format!("{:?} is not {BUNDLE_FORMAT}", claim.bundle_format), + format!("{:?} is not a supported bundle format", claim.bundle_format), )); } // The raw bytes are checked before the parser sees them: a corrupted @@ -333,7 +389,7 @@ impl Bundle { let manifest_bytes = require_members(&members)?; let manifest = parse_manifest(manifest_bytes)?; - if manifest.bundle_format != BUNDLE_FORMAT { + if manifest.bundle_format != claim.bundle_format { return Err(Error::refuse( WireReason::UnsupportedBundleFormat, format!("the manifest declares {:?}", manifest.bundle_format), @@ -357,6 +413,7 @@ impl Bundle { ), )); } + check_adaptation_bindings(&manifest)?; let files = check_files(&manifest, &members, actual_length)?; // The manifest's own identity is checked after the files, so a bundle @@ -387,6 +444,159 @@ impl Bundle { files, }) } + + /// Bind a v2 manifest to the exact profile selected for this operation. + /// + /// V1 has no such fields and remains unchanged during the ordered rollout. + /// + /// # Errors + /// + /// Refuses a missing, mismatched or out-of-vocabulary v2 binding. + pub fn require_projection_profile(&self, expected: &ProjectionProfile) -> Result<()> { + let Some(binding) = self.manifest.projection_profile.as_ref() else { + return Err(Error::refuse( + WireReason::AdaptationBindingMissing, + "bundle v2 has no projection_profile", + )); + }; + let expected_scope = expected.target_scope.as_deref().unwrap_or("global"); + if binding.profile_id != expected.profile_id + || binding.profile_digest != expected.digest + || binding.target_scope != expected_scope + { + return Err(Error::refuse( + WireReason::ProjectionProfileMismatch, + "bundle v2 was compiled for a different provider projection profile", + )); + } + for adaptation in &self.manifest.component_adaptations { + if !expected + .component_kinds + .contains(&adaptation.provider_component_kind) + || !expected + .projection_kinds + .contains(&adaptation.projection_kind) + { + return Err(Error::refuse( + WireReason::AdaptationBindingMismatch, + format!( + "adaptation for {:?} names a kind outside the selected profile", + adaptation.stable_id + ), + )); + } + } + Ok(()) + } +} + +fn canonical_digest(value: &str) -> bool { + value.len() == 71 + && value.starts_with("sha256:") + && value[7..] + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn canonical_adaptation_id(value: &str) -> bool { + value.len() == 75 + && value.starts_with("adaptation_") + && value[11..] + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// Validate v2's immutable bindings independently of the selected provider profile. +fn check_adaptation_bindings(manifest: &Manifest) -> Result<()> { + if manifest.bundle_format != BUNDLE_FORMAT { + return Err(Error::refuse( + WireReason::UnsupportedBundleFormat, + format!("the manifest declares {:?}", manifest.bundle_format), + )); + } + let Some(profile) = manifest.projection_profile.as_ref() else { + return Err(Error::refuse( + WireReason::AdaptationBindingMissing, + "bundle v2 has no projection_profile", + )); + }; + if manifest.component_adaptations.is_empty() { + return Err(Error::refuse( + WireReason::AdaptationBindingMissing, + "bundle v2 has no component_adaptations", + )); + } + let manifest_scope = manifest.target_scope.as_deref().unwrap_or("global"); + if profile.profile_id.is_empty() + || !canonical_digest(&profile.profile_digest) + || profile.target_scope != manifest_scope + { + return Err(Error::refuse( + WireReason::ProjectionProfileMismatch, + "bundle v2's profile binding is incomplete or names another scope", + )); + } + + let mut prior = None; + let mut owners = BTreeSet::new(); + let mut bound_paths = BTreeSet::new(); + for binding in &manifest.component_adaptations { + if prior.is_some_and(|value: &str| value >= binding.stable_id.as_str()) { + return Err(Error::refuse( + WireReason::AdaptationBindingMismatch, + "component_adaptations are not strictly sorted by stable_id", + )); + } + prior = Some(binding.stable_id.as_str()); + if binding.stable_id.is_empty() + || binding.version.is_empty() + || !canonical_digest(&binding.passport_digest) + || !canonical_adaptation_id(&binding.adaptation_id) + || !canonical_digest(&binding.projection_artifact.digest) + || binding.projection_artifact.size_bytes == 0 + || binding.provider_component_kind.is_empty() + || binding.projection_kind.is_empty() + || binding.member_paths.is_empty() + || !owners.insert(binding.stable_id.as_str()) + { + return Err(Error::refuse( + WireReason::AdaptationBindingMismatch, + format!("adaptation for {:?} is not canonical", binding.stable_id), + )); + } + let mut previous_path = None; + for path in &binding.member_paths { + check_path(path)?; + if previous_path.is_some_and(|value: &str| value >= path.as_str()) { + return Err(Error::refuse( + WireReason::AdaptationBindingMismatch, + format!( + "member_paths for {:?} are not strictly sorted", + binding.stable_id + ), + )); + } + previous_path = Some(path.as_str()); + bound_paths.insert((binding.stable_id.as_str(), path.as_str())); + } + } + let file_owners: BTreeSet<&str> = manifest + .files + .iter() + .map(|file| file.owner.as_str()) + .collect(); + if file_owners != owners + || manifest + .files + .iter() + .any(|file| !bound_paths.contains(&(file.owner.as_str(), file.path.as_str()))) + { + return Err(Error::refuse( + WireReason::AdaptationBindingMismatch, + "bundle files do not close over the declared component adaptations", + )); + } + Ok(()) } /// Require the four documents, once each, in order, before anything else. @@ -881,7 +1091,102 @@ mod tests { ); } + fn v2_manifest() -> Manifest { + serde_json::from_value(serde_json::json!({ + "schema_version": 1, + "bundle_format": BUNDLE_FORMAT, + "protocol_version": BUNDLE_PROTOCOL_VERSION, + "harness_id": "test", + "bundle_digest": "sha256:aa", + "projection_profile": { + "profile_id": "test/native-files/2", + "profile_digest": "sha256:".to_owned() + &"4".repeat(64), + "target_scope": "global" + }, + "component_adaptations": [{ + "stable_id": "component_a", + "version": "1.0", + "passport_digest": "sha256:".to_owned() + &"1".repeat(64), + "adaptation_id": "adaptation_".to_owned() + &"2".repeat(64), + "projection_artifact": { + "digest": "sha256:".to_owned() + &"3".repeat(64), + "size_bytes": 128 + }, + "provider_component_kind": "skill", + "projection_kind": "native_files", + "member_paths": ["skills/a.md"] + }], + "files": [{ + "path": "skills/a.md", + "digest": "sha256:".to_owned() + &"5".repeat(64), + "byte_length": 1, + "mode": 420, + "owner": "component_a" + }] + })) + .unwrap() + } + + #[test] + fn bundle_v2_requires_complete_sorted_adaptation_bindings() { + let manifest = v2_manifest(); + check_adaptation_bindings(&manifest).unwrap(); + + let mut missing = manifest.clone(); + missing.component_adaptations.clear(); + assert_eq!( + check_adaptation_bindings(&missing).unwrap_err().reason(), + Some(WireReason::AdaptationBindingMissing) + ); + + let mut unbound = manifest; + unbound.files[0].owner = "component_b".to_owned(); + assert_eq!( + check_adaptation_bindings(&unbound).unwrap_err().reason(), + Some(WireReason::AdaptationBindingMismatch) + ); + } + + #[test] + fn bundle_v2_is_bound_to_the_exact_provider_profile() { + let manifest = v2_manifest(); + let profile = ProjectionProfile::new( + "test/native-files/2", + &[crate::ComponentKind::Skill], + &[crate::ProjectionKind::NativeFiles], + &["skills"], + &[BUNDLE_FORMAT], + CONTRACT_MAX_FILES, + CONTRACT_MAX_BUNDLE_BYTES, + ) + .unwrap(); + let bundle = Bundle { + manifest: manifest.clone(), + passport: SetupPassport::default(), + files: BTreeMap::new(), + }; + assert_eq!( + bundle + .require_projection_profile(&profile) + .unwrap_err() + .reason(), + Some(WireReason::ProjectionProfileMismatch), + "the manifest carries a deliberately different profile digest" + ); + let mut exact = manifest; + exact.projection_profile.as_mut().unwrap().profile_digest = profile.digest.clone(); + Bundle { + manifest: exact, + passport: SetupPassport::default(), + files: BTreeMap::new(), + } + .require_projection_profile(&profile) + .unwrap(); + } + fn build(files: &[(&str, &str, u32)]) -> Built { + let mut member_paths = files.iter().map(|(path, _, _)| *path).collect::>(); + member_paths.sort_unstable(); let records: Vec = files .iter() .map(|(path, body, mode)| { @@ -891,7 +1196,7 @@ mod tests { "digest": digest::of_bytes(body.as_bytes()), "byte_length": body.len(), "mode": mode, - "owner": "", + "owner": "component_a", }) }) .collect(); @@ -902,6 +1207,24 @@ mod tests { "harness_id": "test", "builder_version": "0.1.0", "input_digest": "sha256:".to_owned() + &"3".repeat(64), + "projection_profile": { + "profile_id": "test/native-files/2", + "profile_digest": "sha256:".to_owned() + &"4".repeat(64), + "target_scope": "global" + }, + "component_adaptations": [{ + "stable_id": "component_a", + "version": "1.0", + "passport_digest": "sha256:".to_owned() + &"1".repeat(64), + "adaptation_id": "adaptation_".to_owned() + &"2".repeat(64), + "projection_artifact": { + "digest": "sha256:".to_owned() + &"3".repeat(64), + "size_bytes": 128 + }, + "provider_component_kind": "instruction", + "projection_kind": "native_files", + "member_paths": member_paths + }], "managed_paths": files.iter().map(|(path, _, _)| *path).collect::>(), "files": records, "limits": { @@ -1278,7 +1601,7 @@ mod tests { fn a_format_tag_this_reader_does_not_know_is_refused_before_anything_else() { let built = build(&[("AGENTS.md", "x", 0o644)]); let mut claim = claim(&built); - claim.bundle_format = "ai-stp-bundle/2"; + claim.bundle_format = RETIRED_BUNDLE_FORMAT_V1; let error = Bundle::read(&built.bytes, claim).unwrap_err(); assert_eq!(error.reason(), Some(WireReason::UnsupportedBundleFormat)); } diff --git a/crates/provider-v3/src/reason.rs b/crates/provider-v3/src/reason.rs index e106f18..80a7698 100644 --- a/crates/provider-v3/src/reason.rs +++ b/crates/provider-v3/src/reason.rs @@ -18,6 +18,10 @@ use setup_core::ReasonCode; /// A refusal the provider is allowed to put on the wire. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum WireReason { + /// A v2 bundle omits the immutable adaptation binding it requires. + AdaptationBindingMissing, + /// A v2 adaptation binding does not close over the files it claims. + AdaptationBindingMismatch, /// The bundle bytes do not hash to the digest that named them. DigestMismatch, /// The bundle exceeds a declared file-count or byte limit. @@ -66,6 +70,8 @@ pub enum WireReason { impl WireReason { /// Every reason, in wire order. pub const ALL: &'static [Self] = &[ + Self::AdaptationBindingMissing, + Self::AdaptationBindingMismatch, Self::DigestMismatch, Self::LimitExceeded, Self::LinkNotAllowed, @@ -91,6 +97,8 @@ impl WireReason { #[must_use] pub const fn as_str(self) -> &'static str { match self { + Self::AdaptationBindingMissing => "adaptation_binding_missing", + Self::AdaptationBindingMismatch => "adaptation_binding_mismatch", Self::DigestMismatch => "digest_mismatch", Self::LimitExceeded => "limit_exceeded", Self::LinkNotAllowed => "link_not_allowed", diff --git a/references/opencode-baseline.json b/references/opencode-baseline.json index 26bede6..94188af 100644 --- a/references/opencode-baseline.json +++ b/references/opencode-baseline.json @@ -8,9 +8,9 @@ "minimum_version_ref": "build/version.json:opencode_min" }, "release": { - "github_release": "https://github.com/anomalyco/opencode/releases/tag/v1.18.26", - "github_release_api": "https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.18.26", - "tag": "v1.18.26", + "github_release": "https://github.com/anomalyco/opencode/releases/tag/v1.18.27", + "github_release_api": "https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.18.27", + "tag": "v1.18.27", "cli_signature": null, "cli_signature_note": "Official CLI zip/tar assets expose GitHub release asset SHA-256 digests but no PGP/cosign signature was published for the CLI assets." }, @@ -109,7 +109,7 @@ "OPENCODE_DISABLE_PROJECT_CONFIG", "OPENCODE_DISABLE_SHARE" ], - "verified_at": "2026-09-02T00:40:45+00:00", + "verified_at": "2026-09-03T14:13:03+00:00", "native_surfaces": { "verified_at": "2026-08-31", "config_home": "~/.config/opencode", @@ -294,44 +294,44 @@ "shape": "gzip-tar", "platforms": { "linux/arm64": { - "url": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.26.tgz", - "bytes": 59947971, - "sha256": "sha256:5e0cc6c6c48d6629c8f5d3d5c9f9670e8dac7ba14d295801bb3f6a783a8f841b", + "url": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.27.tgz", + "bytes": 59945385, + "sha256": "sha256:83bf3812ecad71b3a463c5c0a7ceb0dba9db96964f3e7f8ba6bf30ca138287e8", "member": "package/bin/opencode" }, "linux/x86_64": { - "url": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.26.tgz", - "bytes": 60169535, - "sha256": "sha256:990d8b07111517a78ba779709ff8f438e0dcf2a7fb66d36df7507c8e93358f02", + "url": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.27.tgz", + "bytes": 60168253, + "sha256": "sha256:0aba86ba404f52e57bd154ec3565cd3e86d344743bf32e3004bf7fdbd3363ac4", "member": "package/bin/opencode" }, "macos/arm64": { - "url": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.26.tgz", - "bytes": 45942652, - "sha256": "sha256:d9c09ba039dd62f983fc66c65777910f20eead2c4e30cbff888f26d640607e15", + "url": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.27.tgz", + "bytes": 45940410, + "sha256": "sha256:dba942c12128491b7c00f5d4b395bb8d36061f293b59db501ca9b0911a701680", "member": "package/bin/opencode" }, "macos/x86_64": { - "url": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.26.tgz", - "bytes": 48118308, - "sha256": "sha256:dff2571b3ad3f04dff7f0555bf4e679615c1f70afb35258f139d22a491da57e3", + "url": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.27.tgz", + "bytes": 48115145, + "sha256": "sha256:8e379467c2f911d5a6bb14a453b8f760e093daf8c5c6b9ee1da8f3515477e8f2", "member": "package/bin/opencode" }, "windows/arm64": { - "url": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.26.tgz", - "bytes": 58398040, - "sha256": "sha256:419799338b25d5e62a393136c61166ddf0e78229b784daf0a9fabfb0df66eb9f", + "url": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.27.tgz", + "bytes": 58397893, + "sha256": "sha256:3da5a83466c814922fc1472ef4eef1c37cae990a1cfe1530959c83d3f5b13cda", "member": "package/bin/opencode.exe" }, "windows/x86_64": { - "url": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.26.tgz", - "bytes": 60082922, - "sha256": "sha256:fca4106836f9ca9d9485d010a247d0d928eecfff972b9019ff522b6ba9885934", + "url": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.27.tgz", + "bytes": 60079608, + "sha256": "sha256:d940ca3115e9a87107bb666c30c3efea88bcad1c2d34212c8deb4401a3054792", "member": "package/bin/opencode.exe" } }, - "version": "1.18.26", - "verified_at": "2026-09-02T00:40:45+00:00" + "version": "1.18.27", + "verified_at": "2026-09-03T14:13:03+00:00" }, "setup_catalogue_digest": "sha256:db280f58e88697d8c2b6c041e97da7af323227138b4f65a40baf14c7bdc26d06", "previous_software_artifacts": { @@ -339,44 +339,44 @@ "shape": "gzip-tar", "platforms": { "linux/arm64": { - "url": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.25.tgz", - "bytes": 59965131, - "sha256": "sha256:2b14bd75252cbaec62abd5b3df43da01c4ae521a7e62a2f577af7ea0edd7c7a1", + "url": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.26.tgz", + "bytes": 59947971, + "sha256": "sha256:5e0cc6c6c48d6629c8f5d3d5c9f9670e8dac7ba14d295801bb3f6a783a8f841b", "member": "package/bin/opencode" }, "linux/x86_64": { - "url": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.25.tgz", - "bytes": 60179907, - "sha256": "sha256:3e6d285607b6e9acd1f60ec350cc3954d7351d9dcad970ded390f7b733e34280", + "url": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.26.tgz", + "bytes": 60169535, + "sha256": "sha256:990d8b07111517a78ba779709ff8f438e0dcf2a7fb66d36df7507c8e93358f02", "member": "package/bin/opencode" }, "macos/arm64": { - "url": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.25.tgz", - "bytes": 45945992, - "sha256": "sha256:5a2ba8cdd01e8d9d3b3658cc8aeec27e22c81414a885bbe05af5958b022581c2", + "url": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.26.tgz", + "bytes": 45942652, + "sha256": "sha256:d9c09ba039dd62f983fc66c65777910f20eead2c4e30cbff888f26d640607e15", "member": "package/bin/opencode" }, "macos/x86_64": { - "url": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.25.tgz", - "bytes": 48128085, - "sha256": "sha256:f42ee1f37d6dce61501140357cadfc0c153224e1224dd0ef00fbb073ce538abb", + "url": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.26.tgz", + "bytes": 48118308, + "sha256": "sha256:dff2571b3ad3f04dff7f0555bf4e679615c1f70afb35258f139d22a491da57e3", "member": "package/bin/opencode" }, "windows/arm64": { - "url": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.25.tgz", - "bytes": 58410963, - "sha256": "sha256:33a0d88c0fd16cf93eb6302c2eeefd70c84400bf33c50cc5456993eb43c5cc3a", + "url": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.26.tgz", + "bytes": 58398040, + "sha256": "sha256:419799338b25d5e62a393136c61166ddf0e78229b784daf0a9fabfb0df66eb9f", "member": "package/bin/opencode.exe" }, "windows/x86_64": { - "url": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.25.tgz", - "bytes": 60101564, - "sha256": "sha256:07bcd049b7f1c7ba7184ab97240fb9cd63332fdbfa1d53d84dfbde0f010f4796", + "url": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.26.tgz", + "bytes": 60082922, + "sha256": "sha256:fca4106836f9ca9d9485d010a247d0d928eecfff972b9019ff522b6ba9885934", "member": "package/bin/opencode.exe" } }, - "version": "1.18.25", - "verified_at": "2026-08-29T10:56:16+00:00" + "version": "1.18.26", + "verified_at": "2026-09-02T00:40:45+00:00" }, "source_verified_runtime_flags_note": "All five read out of the 1.18.25 binary on 2026-08-31 -- the whole `OPENCODE_*` set is in its string table, and these are the five this provider has a reason to name. **Nothing in this repository read this block until now.** It is the same shape as the `windows` row that sat under `unsupported` for weeks while this provider installed Windows: a true-when-written list with no reader, which is the condition a stale fact needs. `native_declaration_names_the_switch_it_sets` now ties `updates_off_env` to this list, so the declaration and the measurement cannot drift apart in silence.", "surface_presence": { diff --git a/tools/build_crates_io.py b/tools/build_crates_io.py new file mode 100644 index 0000000..30e85b4 --- /dev/null +++ b/tools/build_crates_io.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Build seven self-contained crates.io source packages from the shared tree. + +The public repositories are workspaces because that is the clearest form for +reading and contributing. crates.io publishes one package at a time and rejects +unpublished path dependencies. This projection therefore nests the three +shared crates as private modules inside each harness package. The source remains +single-authority here; no generated package is committed. +""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +HARNESSES = ( + "antigravity", + "claude", + "codex", + "cursor", + "grok", + "opencode", + "pi", +) +MODULES = { + "setup_core": "setup-core", + "provider_v3": "provider-v3", + "harness_runtime": "harness-runtime", +} +PRODUCTS = { + "antigravity": "Antigravity CLI", + "claude": "Claude Code", + "codex": "Codex CLI", + "cursor": "Cursor CLI", + "grok": "Grok Build", + "opencode": "OpenCode", + "pi": "Pi Coding Agent", +} + + +def version() -> str: + text = (ROOT / "Cargo.toml").read_text(encoding="utf-8") + return re.search(r'(?m)^version = "([^"]+)"$', text).group(1) # type: ignore[union-attr] + + +def external_paths(text: str) -> str: + for name in MODULES: + text = re.sub(rf"(? str: + text = text.replace("crate::", f"crate::{module}::") + return external_paths(text).replace("../../../provider-kit/", "../../provider-kit/") + + +def cargo_toml(harness: str, release: str) -> str: + package = f"{harness}-setup-system" + product = PRODUCTS[harness] + return f'''[package] +name = "{package}" +version = "{release}" +edition = "2024" +rust-version = "1.89" +license = "AGPL-3.0-or-later" +description = "Install, update, back up, restore and remove complete {product} configurations. Built by NDDev." +repository = "https://github.com/NDDev-OpenNetwork/{package}" +homepage = "https://nddev.it.com" +readme = "README.md" +keywords = ["ai", "agent", "setup", "backup", "cli"] +categories = ["command-line-utilities", "development-tools"] +publish = ["crates-io"] + +[dependencies] +serde = {{ version = "1", features = ["derive"] }} +serde_json = {{ version = "1", features = ["preserve_order"] }} +sha2 = "0.11" +miniz_oxide = "0.9" + +[profile.release] +lto = true +codegen-units = 1 +strip = "symbols" +panic = "abort" + +[workspace] +''' + + +def readme(harness: str) -> str: + package = f"{harness}-setup-system" + return f"""# {package} + +The NDDev setup system for {PRODUCTS[harness]}. It installs complete native +configurations through an explicit target, captures a backup before every +mutation, and restores exact bytes. It implements the `ai-stp` provider +protocol v3 and accepts adaptation-bound `ai-stp-bundle/2` packages. + +```console +cargo install {package} +{package} list +{package} provider-info +``` + +Source, security policy and release provenance: +. +""" + + +def build(harness: str, out_root: Path, release: str) -> Path: + package = f"{harness}-setup-system" + out = out_root / package + if out.exists(): + shutil.rmtree(out) + (out / "src").mkdir(parents=True) + + for module, crate in MODULES.items(): + destination = out / "src" / module + destination.mkdir() + for source in sorted((ROOT / "crates" / crate / "src").glob("*.rs")): + name = "mod.rs" if source.name == "lib.rs" else source.name + destination.joinpath(name).write_text( + nested_source(source.read_text(encoding="utf-8"), module), + encoding="utf-8", + ) + + source_root = ROOT / "crates" / package + main = source_root.joinpath("src/main.rs").read_text(encoding="utf-8") + main = main.replace("mod software;", "") + main = external_paths(main).replace("../../../provider-kit/", "../provider-kit/") + split = main.index("\nuse std::process::ExitCode;") + docs, body = main[:split], main[split:] + modules = "\n".join(f"mod {name};" for name in (*MODULES, "software")) + projection_lints = """#![allow( + dead_code, + unused_imports, + reason = "the standalone crate nests the complete shared implementation; public workspace APIs unused by this harness remain intentionally present" +)]""" + (out / "src/main.rs").write_text( + f"{docs}\n\n{projection_lints}\n\n{modules}\n{body}", encoding="utf-8" + ) + software = source_root.joinpath("src/software.rs").read_text(encoding="utf-8") + (out / "src/software.rs").write_text(external_paths(software), encoding="utf-8") + + build_rs = source_root.joinpath("build.rs").read_text(encoding="utf-8") + build_rs = build_rs.replace( + 'let root = manifest.join("..").join("..").join("setups");', + 'let root = manifest.join("setups");', + ) + (out / "build.rs").write_text(build_rs, encoding="utf-8") + shutil.copytree(ROOT / "provider-kit", out / "provider-kit") + scoped_catalog = ROOT / "setups" / harness + shutil.copytree(scoped_catalog if scoped_catalog.is_dir() else ROOT / "setups", out / "setups") + (out / "Cargo.toml").write_text(cargo_toml(harness, release), encoding="utf-8") + (out / "README.md").write_text(readme(harness), encoding="utf-8") + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path) + parser.add_argument("--version", default=version()) + parser.add_argument("--harness", action="append", choices=HARNESSES) + parser.add_argument("--self-check", action="store_true") + args = parser.parse_args() + if args.self_check: + with tempfile.TemporaryDirectory(prefix="nddev-crates-io-") as temporary: + root = Path(temporary) + for harness in HARNESSES: + package = build(harness, root, args.version) + document = package.joinpath("Cargo.toml").read_text(encoding="utf-8") + expected = f'name = "{harness}-setup-system"' + if expected not in document or "nddev-" + harness in document: + raise SystemExit(f"{harness}: generated package name is not {expected}") + subprocess.run( + ["cargo", "package", "--manifest-path", str(package / "Cargo.toml"), "--no-verify"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + codex = root / "codex-setup-system" + subprocess.run( + ["cargo", "build", "--quiet", "--manifest-path", str(codex / "Cargo.toml")], + check=True, + ) + answer = subprocess.run( + [codex / "target/debug/codex-setup-system", "provider-info"], + check=True, + capture_output=True, + text=True, + ) + info = json.loads(answer.stdout) + if info["provider_id"] != "codex-setup-system" or info["projection_profile"][ + "bundle_formats" + ] != ["ai-stp-bundle/2"]: + raise SystemExit("the installed-shape provider-info is not the v2-only Codex provider") + print("crates.io: seven same-name packages; all package, and a standalone provider runs") + return 0 + if args.out is None: + parser.error("--out is required unless --self-check is used") + for harness in args.harness or HARNESSES: + print(build(harness, args.out, args.version)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())