diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb3c312..ef72335 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: name: rust permissions: contents: read - uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/rust-ci.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/rust-ci.yml@e90a2c7c6c25cd1fb9ac1c68adbc7535f8b17e1e with: toolchain: '1.98.0' # The three-OS matrix is the evidence ADR-0113 asks for, and standard @@ -46,7 +46,7 @@ jobs: name: supply-chain permissions: contents: read - uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/rust-supply-chain.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/rust-supply-chain.yml@e90a2c7c6c25cd1fb9ac1c68adbc7535f8b17e1e with: enable_deny: true enable_audit: true @@ -56,7 +56,7 @@ jobs: name: actionlint permissions: contents: read - uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/actionlint.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/actionlint.yml@e90a2c7c6c25cd1fb9ac1c68adbc7535f8b17e1e pr-hygiene: name: pr-hygiene @@ -68,7 +68,7 @@ jobs: # that would use them is off. issues: write # the stale bot closes threads pull-requests: write # the labeler applies labels - uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/pr-hygiene.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/pr-hygiene.yml@e90a2c7c6c25cd1fb9ac1c68adbc7535f8b17e1e boundary: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b09e376..11a7bac 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-OpenNetwork/ci-workflows/.github/workflows/public-codeql.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/public-codeql.yml@e90a2c7c6c25cd1fb9ac1c68adbc7535f8b17e1e 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-OpenNetwork/ci-workflows/.github/workflows/public-dependency-review.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/public-dependency-review.yml@e90a2c7c6c25cd1fb9ac1c68adbc7535f8b17e1e 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-OpenNetwork/ci-workflows/.github/workflows/public-scorecard.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/public-scorecard.yml@e90a2c7c6c25cd1fb9ac1c68adbc7535f8b17e1e osv: name: osv permissions: contents: read - uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/osv-scan.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/osv-scan.yml@e90a2c7c6c25cd1fb9ac1c68adbc7535f8b17e1e 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-OpenNetwork/ci-workflows/.github/workflows/zizmor-sarif.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/zizmor-sarif.yml@e90a2c7c6c25cd1fb9ac1c68adbc7535f8b17e1e secret-scan: name: secret-scan permissions: contents: read - uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/secret-scan.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/secret-scan.yml@e90a2c7c6c25cd1fb9ac1c68adbc7535f8b17e1e diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f1931c..7909e52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,34 @@ sibling project the same week this note was added. ## [Unreleased] +## [0.0.57] - 2026-09-02 + +A kind declared only by a scoped profile validates and plans under +that scope. `validate-bundle` compared a bundle's component kinds against the +global profile whatever the surface, so a kind a provider implements only +elsewhere — codex's `skill`, which lives under `~/.agents` — was refused, and +every scoped plan carrying it with it. Now `validate-bundle` accepts a kind +any declared profile implements, the question it can answer with no scope in +its argv, and a plan under a scope checks kinds against that scope's profile; +a global plan carrying a scoped-only kind refuses by name. Found by the +consumer's user_root slice: four providers passed because they declare the +kind globally too, codex had never passed. + +A target the system cannot canonicalize is taken as given. Inside the +consumer's Windows AppContainer, `GetFinalPathNameByHandle` cannot map a +volume back to a drive letter for any path, so every `status` refused with +"cannot be canonicalized" while `provider-info` answered — measured by the +consumer four ways. Now, once the directory has been inspected and its final +component is not a link, a failed canonicalization falls back to the lexical +absolute path; the verbatim `\?\` prefix is dropped everywhere so one +directory carries one `canonical_target` string inside and outside a +container; and the operating system's own error travels in the refusal +detail. The seven public clones the render check makes now identify as the +job's token rather than as a shared address. + +Antigravity is pinned at 1.1.24, published since the previous release. One +Cursor citation moved and is re-cited. + ## [0.0.56] - 2026-09-02 `status_request_fields` is declared: `["target_scope"]`, the same diff --git a/Cargo.lock b/Cargo.lock index a133c96..d3eeb73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,7 +66,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "harness-runtime" -version = "0.0.56" +version = "0.0.57" dependencies = [ "provider-v3", "serde", @@ -128,7 +128,7 @@ dependencies = [ [[package]] name = "opencode-setup-system" -version = "0.0.56" +version = "0.0.57" dependencies = [ "harness-runtime", "provider-v3", @@ -147,7 +147,7 @@ dependencies = [ [[package]] name = "provider-v3" -version = "0.0.56" +version = "0.0.57" dependencies = [ "serde", "serde_json", @@ -209,7 +209,7 @@ dependencies = [ [[package]] name = "setup-core" -version = "0.0.56" +version = "0.0.57" dependencies = [ "miniz_oxide", "serde", diff --git a/Cargo.toml b/Cargo.toml index cfd5d41..123400d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.0.56" +version = "0.0.57" edition = "2024" rust-version = "1.89" license = "AGPL-3.0-or-later" @@ -23,9 +23,9 @@ sha2 = "0.11" # `setup-core::archive`); an inflate loop is not, because its bugs are # memory-safety bugs and it is not improved by being hand-written here. miniz_oxide = "0.9" -setup-core = { path = "crates/setup-core", version = "0.0.56" } -provider-v3 = { path = "crates/provider-v3", version = "0.0.56" } -harness-runtime = { path = "crates/harness-runtime", version = "0.0.56" } +setup-core = { path = "crates/setup-core", version = "0.0.57" } +provider-v3 = { path = "crates/provider-v3", version = "0.0.57" } +harness-runtime = { path = "crates/harness-runtime", version = "0.0.57" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index edbd0b7..6218ef4 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ release is a convenience, not the authorised copy. ```bash docker run --rm -v "$HOME/.config:/config" \ - ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.56 \ + ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.57 \ status --target /config/ --json ``` diff --git a/crates/harness-runtime/src/facts.rs b/crates/harness-runtime/src/facts.rs index 2b8d14c..77ed989 100644 --- a/crates/harness-runtime/src/facts.rs +++ b/crates/harness-runtime/src/facts.rs @@ -353,6 +353,37 @@ impl Harness { Self::within(self.owned_projection(scope), path) } + /// The component kinds the profile at `scope` declares. + #[must_use] + pub fn kinds_at(&self, scope: Option) -> &'static [ComponentKind] { + match self.scoped_for(scope) { + Some(scoped) => scoped.component_kinds, + None => self.component_kinds, + } + } + + /// Whether *any* profile this provider declares implements a kind. + /// + /// The same question `owns_anywhere` answers for a path, asked of a kind. + /// `validate-bundle` is handed no scope, so it can only ask whether this + /// provider could install the bundle at all -- and a kind declared only by + /// a scoped profile (codex's `skill`, which lives under `~/.agents` and + /// not under `~/.codex`) is one it can. Found by the consumer's `user_root` + /// slice on 2026-09-02: four providers passed because they declare the + /// kind globally as well, codex was refused at `validate-bundle` and had + /// never passed it. + #[must_use] + pub fn implements_anywhere(&self, kind: &str) -> bool { + self.component_kinds + .iter() + .chain( + self.scoped_projections + .iter() + .flat_map(|scoped| scoped.component_kinds.iter()), + ) + .any(|declared| declared.as_str() == kind) + } + /// Whether *any* target this provider declares owns a path. /// /// `validate-bundle` is handed a bundle and a target and no scope — that is diff --git a/crates/harness-runtime/src/wire.rs b/crates/harness-runtime/src/wire.rs index 76628cb..68468a2 100644 --- a/crates/harness-runtime/src/wire.rs +++ b/crates/harness-runtime/src/wire.rs @@ -106,7 +106,7 @@ fn verified_bundle(harness: &Harness, bundle: &ArgvBundle, surface: Surface) -> }, )?; check_within_surface(harness, verified.files.keys(), surface)?; - check_declared_kinds(harness, &verified)?; + check_declared_kinds(harness, &verified, surface)?; Ok(verified) } @@ -117,21 +117,41 @@ fn verified_bundle(harness: &Harness, bundle: &ArgvBundle, surface: Surface) -> /// conversion report, which is why a provider that never reads that report /// cannot tell it has been handed a kind it does not implement. It would simply /// write the files and report success for a component it does not understand. -fn check_declared_kinds(harness: &Harness, bundle: &Bundle) -> Result<()> { +/// +/// Asked of the same surface the paths are checked against. This read the +/// global list whatever the surface, so a kind declared only by a scoped +/// profile -- codex's `skill`, which lives under `~/.agents` -- was refused by +/// `validate-bundle` and by every scoped plan, while the four providers that +/// also declare the kind globally passed. The consumer's `user_root` slice +/// found it on 2026-09-02; codex had never passed. `AnyDeclared` now means +/// any profile, and a scope means that scope's profile, exactly as for paths. +fn check_declared_kinds(harness: &Harness, bundle: &Bundle, surface: Surface) -> Result<()> { for entry in &bundle.manifest.conversion_report.entries { if entry.component_type.is_empty() { continue; } - let known = harness - .component_kinds - .iter() - .any(|kind| kind.as_str() == entry.component_type); + let known = match surface { + Surface::AnyDeclared => harness.implements_anywhere(&entry.component_type), + Surface::At(scope) => harness + .kinds_at(scope) + .iter() + .any(|kind| kind.as_str() == entry.component_type), + }; if !known { return Err(Error::refuse( WireReason::UnsupportedComponentKind, format!( - "the bundle declares component {:?} as kind {:?}, which {} does not implement", - entry.stable_id, entry.component_type, harness.provider_id + "the bundle declares component {:?} as kind {:?}, which {} does not implement{}", + entry.stable_id, + entry.component_type, + harness.provider_id, + match surface { + Surface::AnyDeclared => String::new(), + Surface::At(scope) => format!( + " at {}", + scope.map_or("the global profile", provider_v3::TargetScope::as_str) + ), + } ), )); } @@ -5506,6 +5526,81 @@ mod tests { ); } + /// A kind declared only by a scoped profile -- codex's `skill` under + /// `~/.agents` -- is one this provider implements, and `validate-bundle` + /// has no scope to ask about: it must say yes. A scoped plan under that + /// scope says yes; a global plan says no, by name, because the home does + /// not route the kind. Found by the consumer's `user_root` slice: codex had + /// never passed `validate-bundle` with a skill. + #[test] + fn a_kind_declared_only_by_a_scope_validates_and_plans_under_that_scope() { + let mut harness = TEST; + harness.component_kinds = &[ + provider_v3::ComponentKind::Instruction, + provider_v3::ComponentKind::Setting, + ]; + let target = seeded("scoped-only-kind"); + let (bytes, bundle_digest, artifact) = bundle_bytes_declaring( + &[("shared/probe/SKILL.md", "probe\n", 0o644)], + Some("skill"), + ); + let artifact_path = target.join("..").join("scoped-kind.zip"); + fs::write(&artifact_path, &bytes).unwrap(); + let flags = bundle_flags(&artifact_path, &bundle_digest, &artifact, bytes.len()); + let borrowed: Vec<&str> = flags.iter().map(String::as_str).collect(); + + let validated = run_for(&harness, args("validate-bundle", &target, &borrowed)); + assert_eq!(validated["valid"], true, "{validated}"); + + let mut scoped = vec![ + "--operation".to_owned(), + "install".to_owned(), + "--provider-release-digest".to_owned(), + RELEASE.to_owned(), + "--operation-id".to_owned(), + "operation_01SCOPEDKIND".to_owned(), + "--expires-at".to_owned(), + far_future().to_owned(), + "--target-scope".to_owned(), + "user_root".to_owned(), + ]; + scoped.extend(flags.clone()); + let borrowed: Vec<&str> = scoped.iter().map(String::as_str).collect(); + let planned = run_for(&harness, args("plan-operation", &target, &borrowed)); + assert_eq!(planned["state"], "planned", "{planned}"); + + // The same kind on a path the home owns: the surface passes and the + // kind is the refusal, named with the profile that lacks it. + let (bytes, bundle_digest, artifact) = + bundle_bytes_declaring(&[("skills/probe.md", "probe\n", 0o644)], Some("skill")); + let home_path = target.join("..").join("global-kind.zip"); + fs::write(&home_path, &bytes).unwrap(); + let mut global = vec![ + "--operation".to_owned(), + "install".to_owned(), + "--provider-release-digest".to_owned(), + RELEASE.to_owned(), + "--operation-id".to_owned(), + "operation_01GLOBALKIND".to_owned(), + "--expires-at".to_owned(), + far_future().to_owned(), + ]; + global.extend(bundle_flags( + &home_path, + &bundle_digest, + &artifact, + bytes.len(), + )); + 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!( + error.detail().contains("at the global profile"), + "{}", + error.detail() + ); + } + /// A harness shaped like cursor's project scope, for the workspace cases. fn project_shaped() -> Harness { let mut harness = TEST; diff --git a/crates/setup-core/src/target.rs b/crates/setup-core/src/target.rs index 082e98f..2e239b5 100644 --- a/crates/setup-core/src/target.rs +++ b/crates/setup-core/src/target.rs @@ -37,9 +37,12 @@ impl Target { )); } let metadata = fs::symlink_metadata(path).map_err(|source| { + // The operating system's own words travel in the detail: a caller + // inside a container or across a mount cannot otherwise tell + // "not there" from "not allowed", and the consumer asked for it. Error::new( ReasonCode::InvalidTarget, - format!("target {} cannot be inspected", path.display()), + format!("target {} cannot be inspected: {source}", path.display()), ) .with_source(source) })?; @@ -55,15 +58,37 @@ impl Target { format!("target {} is not a directory", path.display()), )); } - let root = fs::canonicalize(path).map_err(|source| { - Error::new( - ReasonCode::InvalidTarget, - format!("target {} cannot be canonicalized", path.display()), - ) - .with_source(source) - })?; + // The final name of the directory that was just inspected. When the + // system cannot say it, the name the caller gave is taken as it is -- + // made absolute and lexically normalized, never re-inspected. + // + // Measured by the consumer on 2026-09-02, four ways, windows-latest, + // inside their AppContainer: `GetFinalPathNameByHandleW(VOLUME_NAME_DOS)` + // fails for *every* path from a LowBox token, because mapping + // `\Device\HarddiskVolumeN` back to a drive letter needs the global + // `\GLOBAL??` object directory the container cannot see; no ACE on any + // ancestor changes that. `symlink_metadata` above had already answered, + // so the directory exists and its final component is not a link. What + // the fallback gives up is the resolution of a reparse point in an + // *ancestor* -- and the caller that runs a provider under a container + // is the one that resolved the path outside it, so the string it hands + // in is the canonical one already; a provider that refused here made + // `status` unanswerable for every target on that platform. + let root = match fs::canonicalize(path) { + Ok(root) => root, + Err(_denied) => std::path::absolute(path).map_err(|source| { + Error::new( + ReasonCode::InvalidTarget, + format!( + "target {} cannot be canonicalized and cannot be made absolute: {source}", + path.display() + ), + ) + .with_source(source) + })?, + }; Ok(Self { - root, + root: without_verbatim_prefix(root), control_directory_name: control_directory_name.to_owned(), }) } @@ -163,6 +188,28 @@ impl Target { } } +/// One directory, one string, whichever way it was resolved. +/// +/// `fs::canonicalize` on Windows answers in the verbatim form (`\\?\C:\…`) +/// and a lexical resolution answers without it, so a target resolved once +/// outside a container and once inside it would carry two `canonical_target` +/// strings for one directory. The prefix adds nothing a reader needs -- the +/// standard library puts it back itself for a path that is too long -- so it +/// is dropped here, and `\\?\UNC\server\share` becomes `\\server\share`. +/// A no-op for every path that never carried it. +fn without_verbatim_prefix(path: PathBuf) -> PathBuf { + let Some(text) = path.to_str() else { + return path; + }; + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{rest}")); + } + if let Some(rest) = text.strip_prefix(r"\\?\") { + return PathBuf::from(rest); + } + path +} + /// Tighten a directory to owner-only access where the platform supports it. /// /// # Errors @@ -225,6 +272,38 @@ mod tests { assert_eq!(error.reason(), ReasonCode::InvalidTarget); } + /// The prefix is dropped wherever it came from, and nothing else moves. + #[test] + fn the_verbatim_prefix_is_dropped_and_a_plain_path_is_kept() { + assert_eq!( + without_verbatim_prefix(PathBuf::from(r"\\?\C:\Users\me\.codex")), + PathBuf::from(r"C:\Users\me\.codex") + ); + assert_eq!( + without_verbatim_prefix(PathBuf::from(r"\\?\UNC\host\share\dir")), + PathBuf::from(r"\\host\share\dir") + ); + assert_eq!( + without_verbatim_prefix(PathBuf::from("/home/me/.codex")), + PathBuf::from("/home/me/.codex") + ); + } + + /// Whatever the platform resolves to, the root a caller reads back never + /// carries the verbatim prefix -- the string the consumer compares against + /// its own resolution is the plain one. + #[test] + fn a_resolved_root_never_carries_the_verbatim_prefix() { + let base = scratch("plain"); + let target = Target::resolve(&base, ".ctl").unwrap(); + assert!( + !target.root().to_string_lossy().starts_with(r"\\?\"), + "{}", + target.root().display() + ); + assert!(target.root().is_absolute()); + } + #[cfg(unix)] #[test] fn a_symlinked_final_component_is_refused_before_it_can_be_swapped() { @@ -242,7 +321,11 @@ mod tests { let base = scratch("control"); let target = Target::resolve(&base, ".ctl").unwrap(); let control = target.ensure_control_directory().unwrap(); - assert_eq!(control, base.join(".ctl")); + // `scratch` canonicalizes, which on Windows answers in the verbatim + // form; the root does not carry it, so the expectation must not + // either. This compared the raw join and went red on windows-latest + // the day the prefix was dropped -- the public matrix's job. + assert_eq!(control, without_verbatim_prefix(base).join(".ctl")); assert!(control.is_dir()); } diff --git a/install.ps1 b/install.ps1 index 124ae07..c895837 100644 --- a/install.ps1 +++ b/install.ps1 @@ -7,7 +7,7 @@ # powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.1.0 [CmdletBinding()] param( - [string]$Version = "0.0.56", + [string]$Version = "0.0.57", [string]$InstallDir = "$env:LOCALAPPDATA\Programs\opencode-setup-system" ) $ErrorActionPreference = "Stop" diff --git a/install.sh b/install.sh index b7e4361..3d59aa7 100644 --- a/install.sh +++ b/install.sh @@ -14,7 +14,7 @@ set -eu REPO="NDDev-OpenNetwork/opencode-setup-system" BINARY="opencode-setup-system" -VERSION="${1:-0.0.56}" +VERSION="${1:-0.0.57}" PREFIX="${OPENCODE_INSTALL_DIR:-$HOME/.local/bin}" case "$(uname -s)" in