From f114a875692b59e9d387e22bb88ee27db212f86b Mon Sep 17 00:00:00 2001 From: Danil Silantyev Date: Tue, 13 Oct 2026 11:45:28 +0500 Subject: [PATCH] feat: prepare recoverable configuration operations Published at 0.0.94. Propose changes through this repository's issues and pull requests. --- .gds/repository.yaml | 9 +- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/workflows/ci.yml | 8 +- .github/workflows/security.yml | 12 +- CHANGELOG.md | 19 ++++ Cargo.lock | 8 +- Cargo.toml | 10 +- README.md | 2 +- SECURITY.md | 2 +- crates/harness-runtime/src/wire.rs | 9 ++ crates/harness-runtime/src/wire/forward.rs | 35 ++++++ .../harness-runtime/src/wire/tests/forward.rs | 103 ++++++++++++++++++ crates/provider-v3/src/argv.rs | 19 +++- crates/setup-core/src/forward.rs | 62 ++++++++--- crates/setup-core/src/forward/preparation.rs | 57 ++++++++++ crates/setup-core/src/forward/store.rs | 74 ++++++++++++- crates/setup-core/src/forward/tests.rs | 85 +++++++++++++++ install.ps1 | 4 +- install.sh | 4 +- scripts/evidence.py | 2 +- tools/build_crates_io.py | 4 +- tools/build_wheels.py | 2 +- 22 files changed, 479 insertions(+), 53 deletions(-) create mode 100644 crates/setup-core/src/forward/preparation.rs diff --git a/.gds/repository.yaml b/.gds/repository.yaml index 640fa18..47ec839 100644 --- a/.gds/repository.yaml +++ b/.gds/repository.yaml @@ -9,14 +9,17 @@ repository: provider: type: "github" - installation: "installation:github-opennetwork" + installation: "installation:github-nddev-technocracy-opennetwork" repository_id: 1344237290 - owner: "NDDev-OpenNetwork" + owner: "NDDev-Technocracy-OpenNetwork" name: "opencode-setup-system" + aliases: + - owner: "NDDev-Technocracy-Sandbox" + name: "opencode-setup-system" classification: portfolios: - - "portfolio:nddev-technocracy-sandbox" + - "portfolio:nddev-technocracy-opennetwork" visibility_contract: "public" data_classification: "public" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 73569f3..e172317 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - name: Security report - url: https://github.com/NDDev-OpenNetwork/opencode-setup-system/security/advisories/new + url: https://github.com/NDDev-Technocracy-OpenNetwork/opencode-setup-system/security/advisories/new about: Report a vulnerability privately. Never open a public issue for one. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1aaa2d..d3cd2df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: name: rust permissions: contents: read - uses: NDDev-Technocracy-Sandbox/ci-workflows/.github/workflows/rust-ci.yml@d92026cf31a10a0eeaa532e1f323c7cfdfbfac5b # 0.1.29 + uses: NDDev-Technocracy-OpenNetwork/ci-workflows/.github/workflows/rust-ci.yml@93eeb2a65e98a29e9dbff7909f67c6419abff046 # commit:93eeb2a65e98a29e9dbff7909f67c6419abff046 with: toolchain: '1.98.1' # The three-OS matrix is the evidence ADR-0113 asks for, and standard @@ -47,7 +47,7 @@ jobs: name: supply-chain permissions: contents: read - uses: NDDev-Technocracy-Sandbox/ci-workflows/.github/workflows/rust-supply-chain.yml@d92026cf31a10a0eeaa532e1f323c7cfdfbfac5b # 0.1.29 + uses: NDDev-Technocracy-OpenNetwork/ci-workflows/.github/workflows/rust-supply-chain.yml@93eeb2a65e98a29e9dbff7909f67c6419abff046 # commit:93eeb2a65e98a29e9dbff7909f67c6419abff046 with: enable_deny: true enable_audit: true @@ -74,7 +74,7 @@ jobs: name: actionlint permissions: contents: read - uses: NDDev-Technocracy-Sandbox/ci-workflows/.github/workflows/actionlint.yml@d92026cf31a10a0eeaa532e1f323c7cfdfbfac5b # 0.1.29 + uses: NDDev-Technocracy-OpenNetwork/ci-workflows/.github/workflows/actionlint.yml@93eeb2a65e98a29e9dbff7909f67c6419abff046 # commit:93eeb2a65e98a29e9dbff7909f67c6419abff046 pr-hygiene: name: pr-hygiene @@ -86,7 +86,7 @@ jobs: # that would use them is off. issues: write # the stale bot closes threads pull-requests: write # the labeler applies labels - uses: NDDev-Technocracy-Sandbox/ci-workflows/.github/workflows/pr-hygiene.yml@d92026cf31a10a0eeaa532e1f323c7cfdfbfac5b # 0.1.29 + uses: NDDev-Technocracy-OpenNetwork/ci-workflows/.github/workflows/pr-hygiene.yml@93eeb2a65e98a29e9dbff7909f67c6419abff046 # commit:93eeb2a65e98a29e9dbff7909f67c6419abff046 boundary: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index c2e4493..e188cfd 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -26,7 +26,7 @@ jobs: actions: read # CodeQL reads the workflow definitions it analyses contents: read security-events: write # CodeQL publishes its findings to code scanning - uses: NDDev-Technocracy-Sandbox/ci-workflows/.github/workflows/public-codeql.yml@d92026cf31a10a0eeaa532e1f323c7cfdfbfac5b # 0.1.29 + uses: NDDev-Technocracy-OpenNetwork/ci-workflows/.github/workflows/public-codeql.yml@93eeb2a65e98a29e9dbff7909f67c6419abff046 # commit:93eeb2a65e98a29e9dbff7909f67c6419abff046 with: languages: '["rust", "actions"]' queries: security-and-quality @@ -37,7 +37,7 @@ jobs: permissions: contents: read pull-requests: write # writes the review it produces - uses: NDDev-Technocracy-Sandbox/ci-workflows/.github/workflows/public-dependency-review.yml@d92026cf31a10a0eeaa532e1f323c7cfdfbfac5b # 0.1.29 + uses: NDDev-Technocracy-OpenNetwork/ci-workflows/.github/workflows/public-dependency-review.yml@93eeb2a65e98a29e9dbff7909f67c6419abff046 # commit:93eeb2a65e98a29e9dbff7909f67c6419abff046 with: fail_on_severity: moderate @@ -52,13 +52,13 @@ jobs: contents: read id-token: write # mints the OIDC token Scorecard's publication is signed with security-events: write # Scorecard publishes its findings to code scanning - uses: NDDev-Technocracy-Sandbox/ci-workflows/.github/workflows/public-scorecard.yml@d92026cf31a10a0eeaa532e1f323c7cfdfbfac5b # 0.1.29 + uses: NDDev-Technocracy-OpenNetwork/ci-workflows/.github/workflows/public-scorecard.yml@93eeb2a65e98a29e9dbff7909f67c6419abff046 # commit:93eeb2a65e98a29e9dbff7909f67c6419abff046 osv: name: osv permissions: contents: read - uses: NDDev-Technocracy-Sandbox/ci-workflows/.github/workflows/osv-scan.yml@d92026cf31a10a0eeaa532e1f323c7cfdfbfac5b # 0.1.29 + uses: NDDev-Technocracy-OpenNetwork/ci-workflows/.github/workflows/osv-scan.yml@93eeb2a65e98a29e9dbff7909f67c6419abff046 # commit:93eeb2a65e98a29e9dbff7909f67c6419abff046 zizmor: name: zizmor @@ -66,10 +66,10 @@ jobs: actions: read # the SARIF upload reads this run to attach its results contents: read security-events: write # zizmor publishes its findings to code scanning - uses: NDDev-Technocracy-Sandbox/ci-workflows/.github/workflows/zizmor-sarif.yml@d92026cf31a10a0eeaa532e1f323c7cfdfbfac5b # 0.1.29 + uses: NDDev-Technocracy-OpenNetwork/ci-workflows/.github/workflows/zizmor-sarif.yml@93eeb2a65e98a29e9dbff7909f67c6419abff046 # commit:93eeb2a65e98a29e9dbff7909f67c6419abff046 secret-scan: name: secret-scan permissions: contents: read - uses: NDDev-Technocracy-Sandbox/ci-workflows/.github/workflows/secret-scan.yml@d92026cf31a10a0eeaa532e1f323c7cfdfbfac5b # 0.1.29 + uses: NDDev-Technocracy-OpenNetwork/ci-workflows/.github/workflows/secret-scan.yml@93eeb2a65e98a29e9dbff7909f67c6419abff046 # commit:93eeb2a65e98a29e9dbff7909f67c6419abff046 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d53855..2d9b296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,25 @@ cut and that this clone does not carry. ## [Unreleased] +## [0.0.94] - 2026-10-13 + +Forward configuration plans now support explicit preparation before any +final configuration write. Preparation retains exact desired bytes and a durable +admission; applying that admission survives plan expiry or loss of the source +bundle. Fresh admission still checks expiry. Cancellation remains available until +publication starts, and replay preserves later operations and unrelated files. + +The first preparation upgrades an idle forward store transactionally while +preserving its receipts. Older writers refuse the upgraded schema, including +after cancellation removes the active guard. Interrupted preparation, guard +publication and cancellation resume through the same exact request. + +Public provider-info and status field sets stay unchanged. ai-stp manages +configuration over user-installed harness executables. Native multi-root +coordination and active-agent handoff remain separate acceptance steps. Current +publisher identities require the compatible ai-stp reader release before these +artifacts are published; historical tags and provenance remain unchanged. + ## [0.0.93] - 2026-10-12 Exact software update and removal plans now accept cancellation before diff --git a/Cargo.lock b/Cargo.lock index 2312413..6420df6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,7 +239,7 @@ checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "harness-runtime" -version = "0.0.93" +version = "0.0.94" dependencies = [ "jsonc-parser", "provider-v3", @@ -422,7 +422,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "opencode-setup-system" -version = "0.0.93" +version = "0.0.94" dependencies = [ "harness-runtime", "provider-v3", @@ -446,7 +446,7 @@ dependencies = [ [[package]] name = "provider-v3" -version = "0.0.93" +version = "0.0.94" dependencies = [ "serde", "serde_json", @@ -597,7 +597,7 @@ dependencies = [ [[package]] name = "setup-core" -version = "0.0.93" +version = "0.0.94" dependencies = [ "cap-fs-ext", "cap-std", diff --git a/Cargo.toml b/Cargo.toml index e3bafef..aae894b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,11 +8,11 @@ members = [ ] [workspace.package] -version = "0.0.93" +version = "0.0.94" edition = "2024" rust-version = "1.89" license = "AGPL-3.0-or-later" -repository = "https://github.com/NDDev-OpenNetwork/opencode-setup-system" +repository = "https://github.com/NDDev-Technocracy-OpenNetwork/opencode-setup-system" authors = ["Danil Silantyev / NDDev"] [workspace.dependencies] @@ -41,9 +41,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.93" } -provider-v3 = { path = "crates/provider-v3", version = "0.0.93" } -harness-runtime = { path = "crates/harness-runtime", version = "0.0.93" } +setup-core = { path = "crates/setup-core", version = "0.0.94" } +provider-v3 = { path = "crates/provider-v3", version = "0.0.94" } +harness-runtime = { path = "crates/harness-runtime", version = "0.0.94" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 8481bb6..f65e0c4 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ does not acquire release publisher authentication by passing conformance. ```bash docker run --rm -v "$HOME/.config:/config" \ - ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.93 \ + ghcr.io/nddev-technocracy-opennetwork/opencode-setup-system:0.0.94 \ status --target /config/ --json ``` diff --git a/SECURITY.md b/SECURITY.md index dab21ca..bf0f66c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,7 +10,7 @@ latest release is supported. ## Reporting a vulnerability Report privately through -[GitHub Security Advisories](https://github.com/NDDev-OpenNetwork/opencode-setup-system/security/advisories/new). +[GitHub Security Advisories](https://github.com/NDDev-Technocracy-OpenNetwork/opencode-setup-system/security/advisories/new). Do not publish exploit details, credentials, tokens, private configuration or backup contents in an issue or pull request. A backup slot may contain whatever diff --git a/crates/harness-runtime/src/wire.rs b/crates/harness-runtime/src/wire.rs index 1ed6745..790497a 100644 --- a/crates/harness-runtime/src/wire.rs +++ b/crates/harness-runtime/src/wire.rs @@ -65,6 +65,7 @@ pub fn dispatch(harness: &Harness, invocation: Invocation) -> Result Result Result, +) -> Result { + execute(harness, path, plan, plan_digest, release, bundle, false) +} + +pub(super) fn prepare( + harness: &Harness, + path: &Path, + plan: &serde_json::Value, + plan_digest: &str, + release: &str, + bundle: Option<&ArgvBundle>, +) -> Result { + execute(harness, path, plan, plan_digest, release, bundle, true) +} + +fn execute( + harness: &Harness, + path: &Path, + plan: &serde_json::Value, + plan_digest: &str, + release: &str, + bundle: Option<&ArgvBundle>, + prepare_only: bool, ) -> Result { let (target, admission) = bound(harness, path, plan, plan_digest, release, bundle)?; let scope = scope_of(plan); let mut writer = Writer::acquire(&target, harness.control_directory)?; match writer.progress(&admission)? { Progress::Completed(outcome) => return Ok(outcome), + Progress::Prepared(_) if prepare_only => { + return writer.prepared(&admission).map_err(Error::from); + } + Progress::Applying(_) | Progress::Finishing if prepare_only => return Err(refused()), Progress::Cancelled(_) => { return Err(Error::refuse( WireReason::Stale, @@ -214,8 +241,16 @@ pub(super) fn apply( scope, )?; let context = serde_json::to_value(&assembly.context).map_err(|_| refused())?; + if prepare_only { + return writer + .prepare(&admission, &context, &assembly.changes) + .map_err(Error::from); + } writer.admit(&admission, &context, &assembly.changes)?; } + if matches!(writer.progress(&admission)?, Progress::Prepared(_)) { + writer.start(&admission)?; + } if let Progress::Applying(context) = writer.progress(&admission)? { let context: Context = serde_json::from_value(context).map_err(|_| refused())?; if writer.read(harness.state_file)?.0 != context.state_before diff --git a/crates/harness-runtime/src/wire/tests/forward.rs b/crates/harness-runtime/src/wire/tests/forward.rs index a142520..aceea02 100644 --- a/crates/harness-runtime/src/wire/tests/forward.rs +++ b/crates/harness-runtime/src/wire/tests/forward.rs @@ -87,6 +87,109 @@ fn remove_request(target: &Path, id: &str) -> (serde_json::Value, Vec) { (planned, apply) } +#[test] +fn prepared_configuration_cancels_or_resumes_after_expiry_without_source() { + use setup_core::forward::{Admission, Progress, Writer}; + let target = seeded("forward-preparation"); + let before = run(args("status", &target, &[])); + let (_, apply) = request( + &target, + "operation_PREPARE_CANCEL", + &[("skills/prepared.md", "desired", 0o644)], + ); + let mut prepare = apply.clone(); + prepare.push("--prepare".into()); + let receipt = run(prepare.clone()); + assert_eq!(receipt["state"], "prepared"); + assert_eq!(receipt["configuration_changed"], false); + assert_eq!(run(prepare), receipt); + assert!(!target.join("skills/prepared.md").exists()); + assert_eq!(fs::read(target.join("AGENTS.md")).unwrap(), b"# first\n"); + let mut cancel = apply; + cancel.push("--cancel".into()); + assert_eq!(run(cancel)["state"], "cancelled"); + assert_eq!(run(args("status", &target, &[])), before); + + let (_, apply) = request( + &target, + "operation_PREPARE_APPLY", + &[("skills/prepared.md", "desired", 0o644)], + ); + let mut prepare = apply.clone(); + prepare.push("--prepare".into()); + assert_eq!(run(prepare.clone())["state"], "prepared"); + let bundle = apply.windows(2).find(|pair| pair[0] == "--bundle").unwrap()[1].clone(); + fs::remove_file(bundle).unwrap(); + let installed = run(apply); + assert_eq!(installed["state"], "verified"); + assert_eq!( + fs::read(target.join("skills/prepared.md")).unwrap(), + b"desired" + ); + + let (mut removal, withdraw) = remove_request(&target, "operation_PREPARE_REMOVE"); + let mut prepare_remove = withdraw.clone(); + prepare_remove.push("--prepare".into()); + run(prepare_remove); + let resolved = Target::resolve(&target, TEST.control_directory).unwrap(); + let admission = Admission { + operation_id: removal["plan"]["operation_id"].as_str().unwrap().into(), + plan_digest: removal["plan_digest"].as_str().unwrap().into(), + operation: "remove".into(), + target_scope: None, + target_precondition_digest: removal["plan"]["expected_target_digest"] + .as_str() + .unwrap() + .into(), + }; + let context = { + let writer = Writer::acquire(&resolved, TEST.control_directory).unwrap(); + let Progress::Prepared(context) = writer.progress(&admission).unwrap() else { + panic!("preparation absent"); + }; + context + }; + let mut cancel = withdraw; + cancel.push("--cancel".into()); + run(cancel); + removal["plan"]["operation_id"] = "operation_PREPARE_EXPIRED".into(); + removal["plan"]["expires_at"] = "2000-01-01T00:00:00.000Z".into(); + removal["plan_digest"] = + digest::of_domain_canonical_json(provider_v3::PLAN_DOMAIN, &removal["plan"]) + .unwrap() + .into(); + let expired = apply_plan(&target, "operation_PREPARE_EXPIRED", &removal, Vec::new()); + let mut expired_prepare = expired.clone(); + expired_prepare.push("--prepare".into()); + assert_eq!(refuse(expired.clone()).reason(), Some(WireReason::Stale)); + assert_eq!( + refuse(expired_prepare.clone()).reason(), + Some(WireReason::Stale) + ); + // Model admission accepted before expiry without a fifteen-minute sleep. + // The wire must distinguish this durable record from a fresh expired request. + { + let mut writer = Writer::acquire(&resolved, TEST.control_directory).unwrap(); + let change = writer.change("skills/prepared.md", None).unwrap(); + let expired_admission = Admission { + operation_id: "operation_PREPARE_EXPIRED".into(), + plan_digest: removal["plan_digest"].as_str().unwrap().into(), + ..admission + }; + writer + .prepare(&expired_admission, &context, &[change]) + .unwrap(); + } + assert_eq!(run(expired_prepare)["state"], "prepared"); + let removed = run(expired.clone()); + assert_eq!(removed["state"], "verified"); + assert!(!target.join("skills/prepared.md").exists()); + assert_eq!(run(expired), removed); + assert_eq!(run(prepare), installed); + assert!(!target.join(TEST.control_directory).join("backups").exists()); + fs::remove_dir_all(target.parent().unwrap()).unwrap(); +} + fn contribution_bytes(path: &str, host: &[u8], assembled: &str) -> (Vec, String, String) { use provider_v3::zip::{ self, diff --git a/crates/provider-v3/src/argv.rs b/crates/provider-v3/src/argv.rs index 868dc03..176b8e2 100644 --- a/crates/provider-v3/src/argv.rs +++ b/crates/provider-v3/src/argv.rs @@ -186,6 +186,10 @@ pub const fn usage(command: Command) -> Usage { "--provider-release-digest", ], optional: &[ + ( + "--prepare", + "durably prepare exact forward configuration without publishing it", + ), ( "--cancel", "cancel an exact forward configuration, software update or removal before effects start", @@ -270,6 +274,8 @@ pub enum Invocation { provider_release_digest: String, /// Persist exact configuration or in-place software cancellation before effects. cancel: bool, + /// Prepare forward configuration without starting publication. + prepare: bool, /// The bundle, when the operation carries one. bundle: Option, /// The program directory, when the operation installs software. @@ -540,6 +546,7 @@ where plan_digest: flags.take_required("--plan-digest")?, provider_release_digest: flags.take_required("--provider-release-digest")?, cancel: flags.take_switch("--cancel"), + prepare: flags.take_switch("--prepare"), bundle: flags.take_bundle()?, prefix: flags.take_prefix()?, software_artifacts: flags @@ -551,6 +558,16 @@ where }; flags.require_exhausted()?; + if matches!( + invocation, + Invocation::ApplyOperation { + cancel: true, + prepare: true, + .. + } + ) { + return Err(local("--prepare and --cancel are mutually exclusive")); + } Ok(invocation) } @@ -635,7 +652,7 @@ impl Flags { if !token.starts_with("--") { return Err(local(format!("{token:?} is not a flag"))); } - if matches!(token.as_str(), "--json" | "--cancel") { + if matches!(token.as_str(), "--json" | "--cancel" | "--prepare") { if switches.iter().any(|switch| switch == token) { return Err(local(format!("{token} was given twice"))); } diff --git a/crates/setup-core/src/forward.rs b/crates/setup-core/src/forward.rs index b97db57..9095f6a 100644 --- a/crates/setup-core/src/forward.rs +++ b/crates/setup-core/src/forward.rs @@ -19,6 +19,7 @@ //! Empty content directories are retained; only admitted file paths are removed. mod files; +mod preparation; mod store; #[cfg(test)] @@ -203,6 +204,8 @@ impl Admission { pub enum Progress { /// No request with this operation identifier has been admitted. Absent, + /// Desired bytes are durable; configuration publication has not started. + Prepared(serde_json::Value), /// Resume desired file publication, using the retained runtime context. Applying(serde_json::Value), /// Final metadata and outcome have been admitted; resume their publication. @@ -337,10 +340,12 @@ impl Writer { return Ok(Progress::Absent); }; match record.phase.as_str() { + "prepared" => Ok(Progress::Prepared(record.context)), "applying" => Ok(Progress::Applying(record.context)), "finishing" => Ok(Progress::Finishing), "completed" => { if record.outcome == Some(admission.cancellation()) { + self.clear_completed(&record)?; return Ok(Progress::Cancelled(record.outcome.ok_or_else(invalid)?)); } self.clear_completed(&record)?; @@ -359,6 +364,16 @@ impl Writer { admission: &Admission, context: &serde_json::Value, changes: &[Change], + ) -> Result<()> { + self.admit_inner(admission, context, changes, false) + } + + fn admit_inner( + &mut self, + admission: &Admission, + context: &serde_json::Value, + changes: &[Change], + prepared: bool, ) -> Result<()> { admission.validate()?; validate_changes(changes)?; @@ -375,7 +390,16 @@ impl Writer { return Err(invalid()); } } - self.store.admit(admission, context, changes) + self.store.admit(admission, context, changes, prepared) + } + + fn unchanged(&self, admission: &Admission) -> Result<()> { + for change in self.store.changes(&admission.operation_id)? { + if self.read(&change.path)?.0 != change.before { + return Err(invalid()); + } + } + Ok(()) } /// Cancel an exact request only while no configuration publication can have started. @@ -392,22 +416,27 @@ impl Writer { let record = self.store.record(admission)?; if let Some(record) = &record { if record.phase == "completed" && record.outcome == Some(outcome.clone()) { + self.clear_completed(record)?; return Ok(outcome); } - if record.phase != "applying" || record.stage.is_some() || self.journal()?.is_some() { - return Err(invalid()); - } - match self.control.symlink_metadata(STAGING) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => (), - _ => return Err(invalid()), - } - for change in self.store.changes(&admission.operation_id)? { - if self.read(&change.path)?.0 != change.before { + if record.phase == "prepared" { + // Complete an interrupted guard publication before retiring + // the preparation. This never writes final configuration. + self.prepared(admission)?; + } else { + if record.phase != "applying" || record.stage.is_some() || self.journal()?.is_some() + { return Err(invalid()); } + match self.control.symlink_metadata(STAGING) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => (), + _ => return Err(invalid()), + } } + self.unchanged(admission)?; } self.store.cancel(admission, &outcome, record.is_some())?; + self.clear_completed(&self.store.record(admission)?.ok_or_else(invalid)?)?; self.revalidate()?; Ok(outcome) } @@ -474,7 +503,7 @@ impl Writer { /// Refuses foreign file changes, changed parent identities or an unrelated journal. pub fn apply(&self, admission: &Admission) -> Result<()> { let record = self.store.record(admission)?.ok_or_else(invalid)?; - if record.phase == "completed" { + if !matches!(record.phase.as_str(), "applying" | "finishing") { return Err(invalid()); } let staging = self.staging(&record)?; @@ -552,7 +581,12 @@ impl Writer { fn clear_completed(&self, record: &store::Record) -> Result<()> { self.revalidate()?; // A historical replay must never clear a newer operation's guard. - if self.journal()? != Some(record.admission.journal()) { + let journal = self.journal()?; + if journal + .as_ref() + .is_some_and(|held| *held != record.admission.journal()) + || (journal.is_none() && (record.stage.is_none() || self.store.has_active()?)) + { return Ok(()); } match self.control.open_dir_nofollow(STAGING) { @@ -569,7 +603,9 @@ impl Writer { Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(invalid().with_source(error)), } - io(self.control.remove_file(crate::journal::JOURNAL_FILE_NAME))?; + if journal.is_some() { + io(self.control.remove_file(crate::journal::JOURNAL_FILE_NAME))?; + } files::sync(&self.control) } } diff --git a/crates/setup-core/src/forward/preparation.rs b/crates/setup-core/src/forward/preparation.rs new file mode 100644 index 0000000..afe4580 --- /dev/null +++ b/crates/setup-core/src/forward/preparation.rs @@ -0,0 +1,57 @@ +//! Durable preparation is distinct from the first configuration publication. + +use super::{Admission, Change, Progress, Result, Writer, invalid, io}; + +impl Writer { + /// Retain desired bytes and publish the legacy-writer guard without applying + /// a configuration change. Repeated preparation verifies the original state. + /// + /// # Errors + /// Refuses changed observations, another active request or begun publication. + pub fn prepare( + &mut self, + admission: &Admission, + context: &serde_json::Value, + changes: &[Change], + ) -> Result { + match self.progress(admission)? { + Progress::Absent => self.admit_inner(admission, context, changes, true)?, + Progress::Prepared(_) => (), + Progress::Completed(outcome) | Progress::Cancelled(outcome) => return Ok(outcome), + Progress::Applying(_) | Progress::Finishing => return Err(invalid()), + } + self.prepared(admission) + } + + /// Finish or replay preparation from its durable record without source bytes. + /// + /// # Errors + /// Refuses missing preparation, physical substitution or configuration drift. + pub fn prepared(&self, admission: &Admission) -> Result { + let record = self.store.record(admission)?.ok_or_else(invalid)?; + if record.phase != "prepared" { + return Err(invalid()); + } + self.unchanged(admission)?; + let staging = self.staging(&record)?; + if io(staging.entries())?.next().is_some() { + return Err(invalid()); + } + self.revalidate()?; + Ok( + serde_json::json!({"state":"prepared", "operation_id":admission.operation_id, + "operation":admission.operation, "plan_digest":admission.plan_digest, + "expected_target_digest":admission.target_precondition_digest, + "configuration_changed":false}), + ) + } + + /// Durably close the cancellation window before any configuration effect. + /// + /// # Errors + /// Refuses absent preparation, drift or a changed operational guard. + pub fn start(&self, admission: &Admission) -> Result<()> { + self.prepared(admission)?; + self.store.start(&admission.operation_id) + } +} diff --git a/crates/setup-core/src/forward/store.rs b/crates/setup-core/src/forward/store.rs index 455fbee..bf87181 100644 --- a/crates/setup-core/src/forward/store.rs +++ b/crates/setup-core/src/forward/store.rs @@ -12,6 +12,7 @@ use crate::{digest, sqlite as vfs}; const OWNER: &str = "CREATE TABLE owner (singleton INTEGER PRIMARY KEY CHECK(singleton=1), identity BLOB NOT NULL) STRICT"; const OPERATIONS: &str = "CREATE TABLE operations (id TEXT PRIMARY KEY, digest TEXT NOT NULL, admission BLOB NOT NULL, context BLOB NOT NULL, stage BLOB, phase TEXT NOT NULL CHECK(phase IN ('applying','finishing','completed')), active INTEGER CHECK(active=1), outcome BLOB, CHECK((active IS NULL)=(phase='completed'))) STRICT, WITHOUT ROWID"; +const PREPARED_OPERATIONS: &str = r#"CREATE TABLE "operations" (id TEXT PRIMARY KEY, digest TEXT NOT NULL, admission BLOB NOT NULL, context BLOB NOT NULL, stage BLOB, phase TEXT NOT NULL CHECK(phase IN ('prepared','applying','finishing','completed')), active INTEGER CHECK(active=1), outcome BLOB, CHECK((active IS NULL)=(phase='completed'))) STRICT, WITHOUT ROWID"#; const ACTIVE: &str = "CREATE UNIQUE INDEX one_active ON operations(active) WHERE active IS NOT NULL"; const CHANGES: &str = "CREATE TABLE changes (operation TEXT NOT NULL, sequence INTEGER NOT NULL, metadata BLOB NOT NULL, payload BLOB, PRIMARY KEY(operation,sequence)) STRICT, WITHOUT ROWID"; @@ -120,8 +121,18 @@ impl Store { let empty = application == 0 && version == 0 && schemas.is_empty(); if !empty && (application != APPLICATION - || version != 1 - || schemas != [CHANGES, ACTIVE, OPERATIONS, OWNER]) + || !matches!(version, 1 | 2) + || schemas + != [ + CHANGES, + ACTIVE, + if version == 1 { + OPERATIONS + } else { + PREPARED_OPERATIONS + }, + OWNER, + ]) { return Err(invalid()); } @@ -258,18 +269,23 @@ impl Store { admission: &Admission, context: &serde_json::Value, changes: &[Change], + prepared: bool, ) -> Result<()> { self.idle()?; + if prepared { + self.enable_preparation()?; + } self.scope.validate()?; sql(self.db.execute_batch("BEGIN IMMEDIATE"))?; let result = (|| { sql(self.db.execute( - "INSERT INTO operations VALUES (?1,?2,?3,?4,NULL,'applying',1,NULL)", + "INSERT INTO operations VALUES (?1,?2,?3,?4,NULL,?5,1,NULL)", params![ admission.operation_id, admission.plan_digest, encode(admission)?, - encode(context)? + encode(context)?, + if prepared { "prepared" } else { "applying" }, ], ))?; self.insert_changes(&admission.operation_id, 0, changes) @@ -277,6 +293,52 @@ impl Store { self.end(result) } + // Only explicit preparation upgrades an idle v1 store. Old in-flight + // operations and ordinary single-root calls keep their original format. + // Schema and user_version jointly fence readers that cannot distinguish + // preparation from publication. Historical receipt bytes are copied intact + // inside one SQLite transaction; no before-image payload is introduced. + fn enable_preparation(&mut self) -> Result<()> { + self.scope.validate()?; + let version: i64 = sql(self.db.query_row("PRAGMA user_version", [], |r| r.get(0)))?; + if version == 2 { + return Ok(()); + } + if version != 1 { + return Err(invalid()); + } + self.idle()?; + sql(self.db.execute_batch("BEGIN IMMEDIATE"))?; + let result = (|| { + sql(self.db.execute_batch(&PREPARED_OPERATIONS.replacen( + "\"operations\"", + "migrating_operations", + 1, + )))?; + sql(self.db.execute_batch("INSERT INTO migrating_operations SELECT * FROM operations; DROP TABLE operations; ALTER TABLE migrating_operations RENAME TO operations;"))?; + sql(self.db.execute_batch(ACTIVE))?; + sql(self.db.pragma_update(None, "user_version", 2))?; + Ok(()) + })(); + self.end(result) + } + + pub(super) fn start(&self, id: &str) -> Result<()> { + self.scope.validate()?; + if sql(self.db.execute("UPDATE operations SET phase='applying' WHERE id=?1 AND phase='prepared' AND stage IS NOT NULL AND active=1", [id]))? != 1 { + return Err(invalid()); + } + Ok(()) + } + + pub(super) fn has_active(&self) -> Result { + sql(self.db.query_row( + "SELECT EXISTS(SELECT 1 FROM operations WHERE active=1)", + [], + |row| row.get(0), + )) + } + pub(super) fn cancel( &mut self, admission: &Admission, @@ -288,7 +350,7 @@ impl Store { let result = (|| { if admitted { if sql(self.db.execute( - "UPDATE operations SET phase='completed',active=NULL,context=x'7b7d',outcome=?1 WHERE id=?2 AND digest=?3 AND phase='applying' AND stage IS NULL AND active=1", + "UPDATE operations SET phase='completed',active=NULL,context=x'7b7d',outcome=?1 WHERE id=?2 AND digest=?3 AND (phase='prepared' OR (phase='applying' AND stage IS NULL)) AND active=1", params![encode(outcome)?, admission.operation_id, admission.plan_digest], ))? != 1 { return Err(invalid()); @@ -329,7 +391,7 @@ impl Store { pub(super) fn set_stage(&self, id: &str, identity: files::Identity) -> Result<()> { self.scope.validate()?; if sql(self.db.execute( - "UPDATE operations SET stage=?1 WHERE id=?2 AND stage IS NULL AND phase='applying'", + "UPDATE operations SET stage=?1 WHERE id=?2 AND stage IS NULL AND phase IN ('prepared','applying')", params![encode(&identity)?, id], ))? != 1 { diff --git a/crates/setup-core/src/forward/tests.rs b/crates/setup-core/src/forward/tests.rs index 78b1acd..4e03b27 100644 --- a/crates/setup-core/src/forward/tests.rs +++ b/crates/setup-core/src/forward/tests.rs @@ -30,6 +30,91 @@ fn content(bytes: &[u8]) -> Content { Content::new(bytes.to_vec(), 0o600).unwrap() } +#[test] +fn preparation_survives_restart_without_opening_the_publication_window() { + for boundary in 0..3 { + let (root, target, admission) = fixture(); + fs::write(root.join("owned"), b"original").unwrap(); + let mut writer = Writer::acquire(&target, ".control").unwrap(); + let old = admission.cancellation(); + assert_eq!(writer.cancel(&admission).unwrap(), old); + let mut prepared = admission.clone(); + prepared.operation_id = "operation_PREPARED".into(); + let change = writer.change("owned", Some(content(b"desired"))).unwrap(); + writer + .admit_inner( + &prepared, + &serde_json::json!({"exact":true}), + &[change], + true, + ) + .unwrap(); + if boundary == 1 { + fs::create_dir(target.control_directory().join(STAGING)).unwrap(); + } else if boundary == 2 { + writer.prepared(&prepared).unwrap(); + } + drop(writer); + let header = fs::read( + target + .control_directory() + .join("forward") + .join(crate::sqlite::DATABASE), + ) + .unwrap(); + assert_eq!(u32::from_be_bytes(header[60..64].try_into().unwrap()), 2); + let mut writer = Writer::acquire(&target, ".control").unwrap(); + assert!( + matches!(writer.progress(&admission).unwrap(), Progress::Cancelled(value) if value == old) + ); + assert!(matches!( + writer.progress(&prepared).unwrap(), + Progress::Prepared(_) + )); + assert!(writer.apply(&prepared).is_err()); + let receipt = writer.prepared(&prepared).unwrap(); + assert_eq!(writer.prepared(&prepared).unwrap(), receipt); + assert_eq!(fs::read(root.join("owned")).unwrap(), b"original"); + assert!( + crate::journal::require_clean_for_planning( + &target.control_directory(), + &target.control_directory().join(STAGING), + &[] + ) + .is_err() + ); + fs::write(root.join("owned"), b"foreign").unwrap(); + assert!(writer.start(&prepared).is_err()); + assert!(writer.cancel(&prepared).is_err()); + fs::write(root.join("owned"), b"original").unwrap(); + if boundary < 2 { + // Simulate loss after the cancellation commit, before guard cleanup. + writer + .store + .cancel(&prepared, &prepared.cancellation(), true) + .unwrap(); + drop(writer); + let mut writer = Writer::acquire(&target, ".control").unwrap(); + assert_eq!(writer.cancel(&prepared).unwrap(), prepared.cancellation()); + assert!(writer.journal().unwrap().is_none()); + assert!(!target.control_directory().join(STAGING).exists()); + assert_eq!(fs::read(root.join("owned")).unwrap(), b"original"); + } else { + writer.start(&prepared).unwrap(); + assert!(writer.cancel(&prepared).is_err()); + drop(writer); + let mut writer = Writer::acquire(&target, ".control").unwrap(); + let outcome = settle(&mut writer, &prepared); + assert!( + matches!(writer.progress(&prepared).unwrap(), Progress::Completed(value) if value == outcome) + ); + assert_eq!(fs::read(root.join("owned")).unwrap(), b"desired"); + } + assert!(!target.control_directory().join("backups").exists()); + fs::remove_dir_all(root).unwrap(); + } +} + #[test] fn cancellation_closes_admission_gaps_without_resurrecting_or_erasing_operations() { for admitted in [false, true] { diff --git a/install.ps1 b/install.ps1 index 81c8e67..9a3f8b0 100644 --- a/install.ps1 +++ b/install.ps1 @@ -7,12 +7,12 @@ # powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.1.0 [CmdletBinding()] param( - [string]$Version = "0.0.93", + [string]$Version = "0.0.94", [string]$InstallDir = $(if ($env:OPENCODE_INSTALL_DIR) { $env:OPENCODE_INSTALL_DIR } else { "$env:LOCALAPPDATA\Programs\opencode-setup-system" }) ) $ErrorActionPreference = "Stop" -$repo = "NDDev-OpenNetwork/opencode-setup-system" +$repo = "NDDev-Technocracy-OpenNetwork/opencode-setup-system" $binary = "opencode-setup-system" $arch = switch ($env:PROCESSOR_ARCHITECTURE) { "AMD64" { "x86_64" } diff --git a/install.sh b/install.sh index 2f0cbaf..65bc454 100644 --- a/install.sh +++ b/install.sh @@ -12,9 +12,9 @@ # sh install.sh 0.1.0 # a specific release set -eu -REPO="NDDev-OpenNetwork/opencode-setup-system" +REPO="NDDev-Technocracy-OpenNetwork/opencode-setup-system" BINARY="opencode-setup-system" -VERSION="${1:-0.0.93}" +VERSION="${1:-0.0.94}" PREFIX="${OPENCODE_INSTALL_DIR:-$HOME/.local/bin}" case "$(uname -s)" in diff --git a/scripts/evidence.py b/scripts/evidence.py index 2744f92..48216cc 100644 --- a/scripts/evidence.py +++ b/scripts/evidence.py @@ -210,7 +210,7 @@ def fetch(url: str, into: Path, expect_bytes: int, expect_digest: str) -> None: headers={ "User-Agent": ( "nddev-setup-system-evidence/1 " - "(+https://github.com/NDDev-OpenNetwork)" + "(+https://github.com/NDDev-Technocracy-OpenNetwork)" ) }, ) diff --git a/tools/build_crates_io.py b/tools/build_crates_io.py index 939fa7d..563f633 100644 --- a/tools/build_crates_io.py +++ b/tools/build_crates_io.py @@ -106,7 +106,7 @@ def cargo_toml(harness: str, release: str) -> str: rust-version = "1.89" license = "AGPL-3.0-or-later" description = "Managed ai-stp installation component for {product}, maintained by NDDev." -repository = "https://github.com/NDDev-OpenNetwork/{package}" +repository = "https://github.com/NDDev-Technocracy-OpenNetwork/{package}" homepage = "https://github.com/ai-engineers-guild/ai-stp" readme = "README.md" keywords = ["ai", "agent", "setup", "harness", "cli"] @@ -145,7 +145,7 @@ def readme(harness: str) -> str: ``` Source, security policy and release provenance: -. +. Configuration backup/restore remains a compatibility feature. Software installation, update and removal use transaction metadata and create no diff --git a/tools/build_wheels.py b/tools/build_wheels.py index 433d0a9..2dc809a 100644 --- a/tools/build_wheels.py +++ b/tools/build_wheels.py @@ -192,7 +192,7 @@ def wheel_bytes( f"Summary: {summary}", "License-Expression: AGPL-3.0-or-later", "Requires-Python: >=3.9", - f"Project-URL: Repository, https://github.com/NDDev-OpenNetwork/{distribution}", + f"Project-URL: Repository, https://github.com/NDDev-Technocracy-OpenNetwork/{distribution}", "Classifier: Operating System :: MacOS", "Classifier: Operating System :: Microsoft :: Windows", "Classifier: Operating System :: POSIX :: Linux",