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