Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ libc = "0.2.186"
schemars = { version = "1", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# SHA-256 for the `what_ran` string length rule and the adapter descriptor
# fingerprint. Std has no SHA-256, and `sha2` 0.10 is already in the lockfile
# through the OpenShell provider.
sha2 = { version = "0.10.9", default-features = false }
thiserror = "2"
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Threading"] }
pyo3 = "0.28.2"
Expand Down
4 changes: 3 additions & 1 deletion crates/fabric-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ workspace = true
jsonschema.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
# Fingerprints parse the authored descriptor and need correctly rounded floats.
serde_json = { workspace = true, features = ["float_roundtrip"] }
sha2.workspace = true
thiserror.workspace = true

[target.'cfg(unix)'.dependencies]
Expand Down
79 changes: 73 additions & 6 deletions crates/fabric-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub use crate::agent_config::{
AgentToolDefinition, AgentToolsConfig, AgentWorkflowConfig, AgentWorkflowEntrypointConfig,
};
use crate::agent_execution::{AgentRunRequest, AgentRunResult};
use crate::descriptor_fingerprint::fingerprint_authored_descriptor;
use crate::error::{FabricError, Result};

/// Versioned NVIDIA NeMo Fabric agent config.
Expand Down Expand Up @@ -425,6 +426,15 @@ pub struct ResolvedAdapterDescriptor {
pub provenance: Vec<DescriptorProvenance>,
/// Adapter-owned compatibility and capability metadata.
pub descriptor: AdapterDescriptor,
/// Fingerprint `nemo.fabric.adapter_descriptor/v1` of the descriptor
/// selected during plan resolution, the first provenance record's file:
/// `sha256:` and the lowercase hex SHA-256 of the RFC 8785 form of the
/// descriptor as authored, before core fills in defaults. Core computes
/// it once, at resolution, and never recomputes or checks it at
/// invocation. Absent in plans from cores that predate it and in
/// standalone inspection.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub descriptor_fingerprint: Option<String>,
}

impl ResolvedAdapterDescriptor {
Expand Down Expand Up @@ -490,6 +500,10 @@ struct DescriptorDiagnostic {
#[derive(Debug, Clone, Default)]
struct DescriptorRegistry {
adapters: BTreeMap<String, Vec<DescriptorRecord<AdapterDescriptor>>>,
/// Authored fingerprint of each adapter descriptor file, keyed by its
/// canonical path, taken from the same bytes the descriptor was loaded
/// from.
adapter_fingerprints: BTreeMap<PathBuf, String>,
targets: BTreeMap<String, Vec<DescriptorRecord<AdapterTargetDescriptor>>>,
diagnostics: Vec<DescriptorDiagnostic>,
}
Expand Down Expand Up @@ -598,8 +612,9 @@ impl DescriptorRegistry {
root,
};
match kind {
DescriptorKind::Adapter => match load_adapter_descriptor(&path) {
Ok(descriptor) => {
DescriptorKind::Adapter => match load_adapter_descriptor_with_fingerprint(&path) {
Ok((descriptor, fingerprint)) => {
self.adapter_fingerprints.insert(path.clone(), fingerprint);
let id = descriptor.adapter_id.clone();
Self::insert_record(&mut self.adapters, id, descriptor, provenance);
}
Expand Down Expand Up @@ -1857,6 +1872,20 @@ pub fn load_adapter_descriptor(path: impl AsRef<Path>) -> Result<AdapterDescript
Ok(descriptor)
}

/// Load an adapter descriptor and fingerprint it as authored, both from
/// one read of the file.
fn load_adapter_descriptor_with_fingerprint(path: &Path) -> Result<(AdapterDescriptor, String)> {
let raw = read_text(path)?;
let parse_error = |source| FabricError::ParseJson {
path: path.to_path_buf(),
source,
};
let descriptor: AdapterDescriptor = serde_json::from_str(&raw).map_err(parse_error)?;
validate_adapter_descriptor_shape(&descriptor, path)?;
let authored: Value = serde_json::from_str(&raw).map_err(parse_error)?;
Ok((descriptor, fingerprint_authored_descriptor(&authored)))
}

/// Load an Adapter Target Descriptor from declarative JSON metadata.
pub fn load_adapter_target_descriptor(path: impl AsRef<Path>) -> Result<AdapterTargetDescriptor> {
let path = path.as_ref();
Expand Down Expand Up @@ -2466,7 +2495,10 @@ pub fn inspect_adapter_config(
.parent()
.expect("canonical descriptor has a parent")
.to_path_buf();
// Inspection returns only the descriptor and telemetry plan, so it
// takes no fingerprint.
let resolved = ResolvedAdapterDescriptor {
descriptor_fingerprint: None,
provenance: vec![DescriptorProvenance {
source: DescriptorSource::ExplicitLocal,
path,
Expand Down Expand Up @@ -2522,14 +2554,18 @@ fn resolve_run_plan_from_config_with_adapter_directories_mode(
resolve_run_plan(config, base_dir, adapter_directories, enforce_compatibility)
}

fn read_text(path: &Path) -> Result<String> {
std::fs::read_to_string(path).map_err(|source| FabricError::Read {
path: path.to_path_buf(),
source,
})
}

fn read_json<T>(path: &Path) -> Result<T>
where
T: for<'de> Deserialize<'de>,
{
let raw = std::fs::read_to_string(path).map_err(|source| FabricError::Read {
path: path.to_path_buf(),
source,
})?;
let raw = read_text(path)?;
serde_json::from_str(&raw).map_err(|source| FabricError::ParseJson {
path: path.to_path_buf(),
source,
Expand Down Expand Up @@ -2937,6 +2973,11 @@ fn resolve_descriptors(
Some(ResolvedAdapterDescriptor {
provenance: adapter_record.provenance.clone(),
descriptor: adapter_record.descriptor.clone(),
descriptor_fingerprint: adapter_record
.provenance
.first()
.and_then(|primary| registry.adapter_fingerprints.get(&primary.path))
.cloned(),
}),
target_record.map(|record| ResolvedAdapterTargetDescriptor {
provenance: record.provenance.clone(),
Expand Down Expand Up @@ -4334,6 +4375,31 @@ mod tests {
assert!(inspect_adapter_config(mismatch, &path).is_err());
}

/// The fingerprint keeps the last of duplicate keys, so a duplicate
/// known field must never reach it: the typed loader rejects one.
#[test]
fn descriptor_with_a_duplicate_known_field_fails_to_load() {
let path = repository_adapter_dir().join("python/hermes/hermes.fabric-adapter.json");
let raw = fs::read_to_string(&path).expect("descriptor");
let duplicated = raw.replacen('{', r#"{"adapter_id": "other.adapter", "#, 1);

let error = serde_json::from_str::<AdapterDescriptor>(&duplicated)
.expect_err("a duplicate known field must fail");

assert!(error.to_string().contains("duplicate field"), "{error}");
}

#[test]
fn host_inspection_leaves_the_descriptor_fingerprint_absent() {
let path = repository_adapter_dir().join("python/hermes/hermes.fabric-adapter.json");

let inspection = inspect_adapter_config(typed_config("nvidia.fabric.hermes"), &path)
.expect("host inspection");

let text = serde_json::to_string(&inspection).expect("inspection JSON");
assert!(!text.contains("descriptor_fingerprint"), "{text}");
}

fn typed_config(adapter_id: &str) -> FabricConfig {
serde_json::from_value(serde_json::json!({
"schema_version": "fabric.agent/v1alpha1",
Expand Down Expand Up @@ -4402,6 +4468,7 @@ mod tests {
path,
}],
descriptor,
descriptor_fingerprint: None,
}
}

Expand Down
Loading
Loading