commit c9ace3d4d43b8084bd42e48ab6e037298de0e242
parent 7347747acd0e764d98f4fb58aa63dfb51a50a93b
Author: triesap <tyson@radroots.org>
Date: Thu, 9 Apr 2026 19:59:22 +0000
config: expose composed runtime bindings
- add explicit capability binding records to the cli runtime config layer
- surface binding posture for myc, radrootsd, rhi, and hyf in config show
- report a read-only binding summary in doctor without invoking providers
- cover explicit binding configuration and inspection paths in cli tests
Diffstat:
6 files changed, 636 insertions(+), 7 deletions(-)
diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs
@@ -60,6 +60,7 @@ pub fn report(
checks.push(hyf_check(&resolve_hyf_status(&config.hyf)));
checks.push(logging_check(config, logging));
+ checks.push(binding_check(config));
let severity = checks
.iter()
@@ -318,6 +319,32 @@ fn logging_check(config: &RuntimeConfig, logging: &LoggingState) -> EvaluatedChe
}
}
+fn binding_check(config: &RuntimeConfig) -> EvaluatedCheck {
+ let inspections = config.inspect_capability_bindings();
+ let mut configured = 0usize;
+ let mut disabled = 0usize;
+ let mut not_configured = 0usize;
+ for inspection in inspections {
+ match inspection.state.as_str() {
+ "configured" => configured += 1,
+ "disabled" => disabled += 1,
+ _ => not_configured += 1,
+ }
+ }
+
+ EvaluatedCheck {
+ severity: DoctorSeverity::Ok,
+ view: DoctorCheckView {
+ name: "bindings".to_owned(),
+ status: "ok".to_owned(),
+ detail: format!(
+ "{configured} configured · {disabled} disabled · {not_configured} not configured"
+ ),
+ },
+ action: None,
+ }
+}
+
fn collect_actions(checks: &[EvaluatedCheck]) -> Vec<String> {
let mut actions = Vec::new();
for action in checks.iter().filter_map(|check| check.action) {
diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs
@@ -1,8 +1,8 @@
use crate::domain::runtime::{
- AccountRuntimeView, AccountSecretRuntimeView, ConfigFilesRuntimeView, ConfigShowView,
- HyfRuntimeView, LegacyPathRuntimeView, LocalRuntimeView, LoggingRuntimeView,
- MigrationRuntimeView, MycRuntimeView, OutputRuntimeView, PathsRuntimeView, RelayRuntimeView,
- RpcRuntimeView, SignerRuntimeView,
+ AccountRuntimeView, AccountSecretRuntimeView, CapabilityBindingRuntimeView,
+ ConfigFilesRuntimeView, ConfigShowView, HyfRuntimeView, LegacyPathRuntimeView,
+ LocalRuntimeView, LoggingRuntimeView, MigrationRuntimeView, MycRuntimeView, OutputRuntimeView,
+ PathsRuntimeView, RelayRuntimeView, RpcRuntimeView, SignerRuntimeView,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::RuntimeConfig;
@@ -112,6 +112,21 @@ pub fn show(
url: config.rpc.url.clone(),
bridge_auth_configured: config.rpc.bridge_bearer_token.is_some(),
},
+ capability_bindings: config
+ .inspect_capability_bindings()
+ .into_iter()
+ .map(|binding| CapabilityBindingRuntimeView {
+ capability_id: binding.capability_id,
+ provider_runtime_id: binding.provider_runtime_id,
+ binding_model: binding.binding_model,
+ state: binding.state.as_str().to_owned(),
+ source: binding.source,
+ target_kind: binding.target_kind,
+ target: binding.target,
+ managed_account_ref: binding.managed_account_ref,
+ signer_session_ref: binding.signer_session_ref,
+ })
+ .collect(),
})
}
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -120,6 +120,7 @@ pub struct ConfigShowView {
pub myc: MycRuntimeView,
pub hyf: HyfRuntimeView,
pub rpc: RpcRuntimeView,
+ pub capability_bindings: Vec<CapabilityBindingRuntimeView>,
}
#[derive(Debug, Clone, Serialize)]
@@ -257,6 +258,23 @@ pub struct RpcRuntimeView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct CapabilityBindingRuntimeView {
+ pub capability_id: String,
+ pub provider_runtime_id: String,
+ pub binding_model: String,
+ pub state: String,
+ pub source: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub target_kind: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub target: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub managed_account_ref: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub signer_session_ref: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct DoctorView {
pub ok: bool,
pub state: String,
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -634,10 +634,48 @@ fn render_config_show(
),
],
)?;
+ writeln!(stdout)?;
+ writeln!(stdout, "capability bindings")?;
+ let table = Table {
+ headers: &["capability", "provider", "state", "target"],
+ rows: view
+ .capability_bindings
+ .iter()
+ .map(|binding| {
+ vec![
+ binding.capability_id.clone(),
+ binding.provider_runtime_id.clone(),
+ binding.state.clone(),
+ format_capability_binding_target(binding),
+ ]
+ })
+ .collect(),
+ };
+ render_table(stdout, &table)?;
writeln!(stdout, "source: {}", view.source)?;
Ok(())
}
+fn format_capability_binding_target(
+ binding: &crate::domain::runtime::CapabilityBindingRuntimeView,
+) -> String {
+ let Some(target) = binding.target.as_ref() else {
+ return String::new();
+ };
+
+ let mut rendered = match binding.target_kind.as_deref() {
+ Some(kind) => format!("{kind} {target}"),
+ None => target.clone(),
+ };
+ if let Some(account_ref) = &binding.managed_account_ref {
+ rendered.push_str(format!(" · account {account_ref}").as_str());
+ }
+ if let Some(session_ref) = &binding.signer_session_ref {
+ rendered.push_str(format!(" · session {session_ref}").as_str());
+ }
+ rendered
+}
+
fn render_doctor(stdout: &mut dyn Write, view: &DoctorView) -> Result<(), RuntimeError> {
write_context(stdout, "system · checks")?;
let table = Table {
@@ -2241,6 +2279,7 @@ mod tests {
url: "http://127.0.0.1:7070".to_owned(),
bridge_bearer_token: None,
},
+ capability_bindings: Vec::new(),
},
&LoggingState {
initialized: true,
@@ -2266,6 +2305,7 @@ mod tests {
assert_eq!(view.relay.publish_policy, "any");
assert!(!view.hyf.enabled);
assert_eq!(view.hyf.executable, "hyfd");
+ assert_eq!(view.capability_bindings.len(), 4);
assert_eq!(
view.account.secret_backend.contract_default_backend,
"host_vault"
@@ -2389,6 +2429,7 @@ mod tests {
url: "http://127.0.0.1:7070".to_owned(),
bridge_bearer_token: None,
},
+ capability_bindings: Vec::new(),
},
&LoggingState {
initialized: true,
diff --git a/src/runtime/config.rs b/src/runtime/config.rs
@@ -227,6 +227,78 @@ pub struct HyfConfig {
pub executable: PathBuf,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CapabilityBindingTargetKind {
+ ManagedInstance,
+ ExplicitEndpoint,
+}
+
+impl CapabilityBindingTargetKind {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::ManagedInstance => "managed_instance",
+ Self::ExplicitEndpoint => "explicit_endpoint",
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CapabilityBindingSource {
+ UserConfig,
+ WorkspaceConfig,
+}
+
+impl CapabilityBindingSource {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::UserConfig => "user config [[capability_binding]]",
+ Self::WorkspaceConfig => "workspace config [[capability_binding]]",
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CapabilityBindingConfig {
+ pub capability_id: String,
+ pub provider_runtime_id: String,
+ pub binding_model: String,
+ pub target_kind: CapabilityBindingTargetKind,
+ pub target: String,
+ pub managed_account_ref: Option<String>,
+ pub signer_session_ref: Option<String>,
+ pub source: CapabilityBindingSource,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CapabilityBindingInspectionState {
+ Configured,
+ NotConfigured,
+ Disabled,
+}
+
+impl CapabilityBindingInspectionState {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Configured => "configured",
+ Self::NotConfigured => "not_configured",
+ Self::Disabled => "disabled",
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CapabilityBindingInspection {
+ pub capability_id: String,
+ pub provider_runtime_id: String,
+ pub binding_model: String,
+ pub state: CapabilityBindingInspectionState,
+ pub source: String,
+ pub target_kind: Option<String>,
+ pub target: Option<String>,
+ pub managed_account_ref: Option<String>,
+ pub signer_session_ref: Option<String>,
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RpcConfig {
pub url: String,
@@ -248,6 +320,7 @@ pub struct RuntimeConfig {
pub myc: MycConfig,
pub hyf: HyfConfig,
pub rpc: RpcConfig,
+ pub capability_bindings: Vec<CapabilityBindingConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -269,6 +342,7 @@ struct CliConfigFile {
relay: Option<RelayFileConfig>,
hyf: Option<HyfFileConfig>,
rpc: Option<RpcFileConfig>,
+ capability_binding: Option<Vec<CapabilityBindingFileConfig>>,
}
#[derive(Debug, Default, Deserialize)]
@@ -288,6 +362,51 @@ struct HyfFileConfig {
executable: Option<PathBuf>,
}
+#[derive(Debug, Clone, Deserialize)]
+struct CapabilityBindingFileConfig {
+ capability: String,
+ provider: String,
+ target_kind: String,
+ target: String,
+ managed_account_ref: Option<String>,
+ signer_session_ref: Option<String>,
+}
+
+#[derive(Debug, Clone, Copy)]
+struct CapabilityBindingSpec {
+ capability_id: &'static str,
+ provider_runtime_id: &'static str,
+ binding_model: &'static str,
+}
+
+const SIGNER_REMOTE_NIP46_CAPABILITY: &str = "signer.remote_nip46";
+const WRITE_PLANE_TRADE_JSONRPC_CAPABILITY: &str = "write_plane.trade_jsonrpc";
+const WORKFLOW_TRADE_CAPABILITY: &str = "workflow.trade";
+const INFERENCE_HYF_STDIO_CAPABILITY: &str = "inference.hyf_stdio";
+
+const CAPABILITY_BINDING_SPECS: &[CapabilityBindingSpec] = &[
+ CapabilityBindingSpec {
+ capability_id: SIGNER_REMOTE_NIP46_CAPABILITY,
+ provider_runtime_id: "myc",
+ binding_model: "session_authorized_remote_signer",
+ },
+ CapabilityBindingSpec {
+ capability_id: WRITE_PLANE_TRADE_JSONRPC_CAPABILITY,
+ provider_runtime_id: "radrootsd",
+ binding_model: "daemon_backed_jsonrpc",
+ },
+ CapabilityBindingSpec {
+ capability_id: WORKFLOW_TRADE_CAPABILITY,
+ provider_runtime_id: "rhi",
+ binding_model: "out_of_process_worker",
+ },
+ CapabilityBindingSpec {
+ capability_id: INFERENCE_HYF_STDIO_CAPABILITY,
+ provider_runtime_id: "hyf",
+ binding_model: "stdio_service",
+ },
+];
+
pub(crate) trait Environment {
fn var(&self, key: &str) -> Option<String>;
fn current_dir(&self) -> Result<PathBuf, RuntimeError>;
@@ -339,6 +458,10 @@ impl RuntimeConfig {
_ => None,
});
Ok(Self {
+ capability_bindings: resolve_capability_bindings(
+ app_config.as_ref(),
+ workspace_config.as_ref(),
+ )?,
output: OutputConfig {
format: resolve_output_format(args, env, env_file)?,
verbosity: resolve_verbosity(args)?,
@@ -460,6 +583,62 @@ impl RuntimeConfig {
)?,
})
}
+
+ pub fn inspect_capability_bindings(&self) -> Vec<CapabilityBindingInspection> {
+ CAPABILITY_BINDING_SPECS
+ .iter()
+ .map(|spec| {
+ if let Some(binding) = self
+ .capability_bindings
+ .iter()
+ .find(|binding| binding.capability_id == spec.capability_id)
+ {
+ return CapabilityBindingInspection {
+ capability_id: binding.capability_id.clone(),
+ provider_runtime_id: binding.provider_runtime_id.clone(),
+ binding_model: binding.binding_model.clone(),
+ state: CapabilityBindingInspectionState::Configured,
+ source: binding.source.as_str().to_owned(),
+ target_kind: Some(binding.target_kind.as_str().to_owned()),
+ target: Some(binding.target.clone()),
+ managed_account_ref: binding.managed_account_ref.clone(),
+ signer_session_ref: binding.signer_session_ref.clone(),
+ };
+ }
+
+ let (state, source) = match spec.capability_id {
+ SIGNER_REMOTE_NIP46_CAPABILITY
+ if matches!(self.signer.backend, SignerBackend::Local) =>
+ {
+ (
+ CapabilityBindingInspectionState::Disabled,
+ "independent local signer mode".to_owned(),
+ )
+ }
+ INFERENCE_HYF_STDIO_CAPABILITY if !self.hyf.enabled => (
+ CapabilityBindingInspectionState::Disabled,
+ "hyf disabled by config".to_owned(),
+ ),
+ _ => (
+ CapabilityBindingInspectionState::NotConfigured,
+ "no explicit capability binding".to_owned(),
+ ),
+ };
+
+ CapabilityBindingInspection {
+ capability_id: spec.capability_id.to_owned(),
+ provider_runtime_id: spec.provider_runtime_id.to_owned(),
+ binding_model: spec.binding_model.to_owned(),
+ state,
+ source,
+ target_kind: None,
+ target: None,
+ managed_account_ref: None,
+ signer_session_ref: None,
+ }
+ })
+ .collect()
+ }
}
fn resolve_migration(paths: PathsConfig, env: &dyn Environment) -> MigrationConfig {
@@ -547,6 +726,132 @@ fn resolve_rpc_config(
})
}
+fn resolve_capability_bindings(
+ user_config: Option<&CliConfigFile>,
+ workspace_config: Option<&CliConfigFile>,
+) -> Result<Vec<CapabilityBindingConfig>, RuntimeError> {
+ let workspace = resolve_file_capability_bindings(
+ workspace_config.and_then(|config| config.capability_binding.as_deref()),
+ CapabilityBindingSource::WorkspaceConfig,
+ )?;
+ let user = resolve_file_capability_bindings(
+ user_config.and_then(|config| config.capability_binding.as_deref()),
+ CapabilityBindingSource::UserConfig,
+ )?;
+
+ let mut merged = BTreeMap::new();
+ for binding in workspace.into_iter().chain(user) {
+ merged.insert(binding.capability_id.clone(), binding);
+ }
+
+ Ok(CAPABILITY_BINDING_SPECS
+ .iter()
+ .filter_map(|spec| merged.remove(spec.capability_id))
+ .collect())
+}
+
+fn resolve_file_capability_bindings(
+ bindings: Option<&[CapabilityBindingFileConfig]>,
+ source: CapabilityBindingSource,
+) -> Result<Vec<CapabilityBindingConfig>, RuntimeError> {
+ let Some(bindings) = bindings else {
+ return Ok(Vec::new());
+ };
+
+ let mut seen = BTreeMap::new();
+ let mut resolved = Vec::with_capacity(bindings.len());
+
+ for binding in bindings {
+ let capability = binding.capability.trim();
+ let provider = binding.provider.trim();
+ let Some(spec) = capability_binding_spec(capability) else {
+ return Err(RuntimeError::Config(format!(
+ "unknown capability_binding capability `{capability}`"
+ )));
+ };
+ if provider != spec.provider_runtime_id {
+ return Err(RuntimeError::Config(format!(
+ "capability_binding `{capability}` must use provider `{}`, got `{provider}`",
+ spec.provider_runtime_id
+ )));
+ }
+ if seen.insert(spec.capability_id.to_owned(), ()).is_some() {
+ return Err(RuntimeError::Config(format!(
+ "capability_binding `{capability}` is duplicated in one config file"
+ )));
+ }
+
+ let target = binding.target.trim();
+ if target.is_empty() {
+ return Err(RuntimeError::Config(format!(
+ "capability_binding `{capability}` target must not be empty"
+ )));
+ }
+
+ let managed_account_ref = normalize_binding_ref(
+ binding
+ .managed_account_ref
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty()),
+ );
+ let signer_session_ref = normalize_binding_ref(
+ binding
+ .signer_session_ref
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty()),
+ );
+ if spec.capability_id != SIGNER_REMOTE_NIP46_CAPABILITY
+ && (managed_account_ref.is_some() || signer_session_ref.is_some())
+ {
+ return Err(RuntimeError::Config(format!(
+ "capability_binding `{capability}` may not set managed_account_ref or signer_session_ref"
+ )));
+ }
+
+ resolved.push(CapabilityBindingConfig {
+ capability_id: spec.capability_id.to_owned(),
+ provider_runtime_id: spec.provider_runtime_id.to_owned(),
+ binding_model: spec.binding_model.to_owned(),
+ target_kind: parse_capability_binding_target_kind(
+ binding.target_kind.as_str(),
+ spec.capability_id,
+ )?,
+ target: target.to_owned(),
+ managed_account_ref,
+ signer_session_ref,
+ source,
+ });
+ }
+
+ Ok(resolved)
+}
+
+fn capability_binding_spec(capability_id: &str) -> Option<CapabilityBindingSpec> {
+ CAPABILITY_BINDING_SPECS
+ .iter()
+ .copied()
+ .find(|spec| spec.capability_id == capability_id)
+}
+
+fn parse_capability_binding_target_kind(
+ value: &str,
+ capability_id: &str,
+) -> Result<CapabilityBindingTargetKind, RuntimeError> {
+ match value.trim().to_ascii_lowercase().as_str() {
+ "managed_instance" => Ok(CapabilityBindingTargetKind::ManagedInstance),
+ "explicit_endpoint" => Ok(CapabilityBindingTargetKind::ExplicitEndpoint),
+ other => Err(RuntimeError::Config(format!(
+ "capability_binding `{capability_id}` target_kind must be `managed_instance` or `explicit_endpoint`, got `{other}`"
+ ))),
+ }
+}
+
+fn normalize_binding_ref(value: Option<&str>) -> Option<String> {
+ value.map(ToOwned::to_owned)
+}
+
fn resolve_relay_config(
args: &CliArgs,
env: &dyn Environment,
@@ -995,9 +1300,11 @@ fn parse_bool_env(key: &str, value: &str) -> Result<bool, RuntimeError> {
#[cfg(test)]
mod tests {
use super::{
- AccountConfig, AccountSecretContractConfig, EnvFileValues, Environment, HyfConfig,
- OutputConfig, OutputFormat, PathsConfig, RelayConfigSource, RelayPublishPolicy,
- RuntimeConfig, SignerBackend, Verbosity, parse_env_file_values,
+ AccountConfig, AccountSecretContractConfig, CapabilityBindingConfig,
+ CapabilityBindingSource, CapabilityBindingTargetKind, EnvFileValues, Environment,
+ HyfConfig, INFERENCE_HYF_STDIO_CAPABILITY, OutputConfig, OutputFormat, PathsConfig,
+ RelayConfigSource, RelayPublishPolicy, RuntimeConfig, SignerBackend, Verbosity,
+ parse_env_file_values,
};
use crate::cli::CliArgs;
use clap::Parser;
@@ -1472,6 +1779,108 @@ RADROOTS_CLI_LOGGING_STDOUT=false
}
#[test]
+ fn user_capability_binding_overrides_workspace_binding() {
+ let temp = tempdir().expect("tempdir");
+ let workspace_root = temp.path().join("workspace");
+ let user_home = temp.path().join("home");
+ fs::create_dir_all(workspace_root.join(".radroots")).expect("workspace config dir");
+ fs::create_dir_all(user_home.join(".radroots/config/apps/cli")).expect("app config dir");
+ fs::write(
+ workspace_root.join(".radroots/config.toml"),
+ r#"
+[[capability_binding]]
+capability = "inference.hyf_stdio"
+provider = "hyf"
+target_kind = "managed_instance"
+target = "workspace-hyf"
+"#,
+ )
+ .expect("write workspace config");
+ fs::write(
+ user_home.join(".radroots/config/apps/cli/config.toml"),
+ r#"
+[[capability_binding]]
+capability = "inference.hyf_stdio"
+provider = "hyf"
+target_kind = "explicit_endpoint"
+target = "bin/user-hyfd"
+"#,
+ )
+ .expect("write user config");
+
+ let env = MapEnvironment {
+ values: BTreeMap::new(),
+ current_dir: workspace_root,
+ path_resolver: RadrootsPathResolver::new(
+ RadrootsPlatform::Linux,
+ RadrootsHostEnvironment {
+ home_dir: Some(user_home),
+ ..RadrootsHostEnvironment::default()
+ },
+ ),
+ };
+ let args = CliArgs::parse_from(["radroots", "config", "show"]);
+
+ let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
+ .expect("resolve config");
+ assert_eq!(resolved.capability_bindings.len(), 1);
+ assert_eq!(
+ resolved.capability_bindings[0],
+ CapabilityBindingConfig {
+ capability_id: INFERENCE_HYF_STDIO_CAPABILITY.to_owned(),
+ provider_runtime_id: "hyf".to_owned(),
+ binding_model: "stdio_service".to_owned(),
+ target_kind: CapabilityBindingTargetKind::ExplicitEndpoint,
+ target: "bin/user-hyfd".to_owned(),
+ managed_account_ref: None,
+ signer_session_ref: None,
+ source: CapabilityBindingSource::UserConfig,
+ }
+ );
+ }
+
+ #[test]
+ fn invalid_capability_binding_provider_fails() {
+ let temp = tempdir().expect("tempdir");
+ let workspace_root = temp.path().join("workspace");
+ let user_home = temp.path().join("home");
+ fs::create_dir_all(workspace_root.join(".radroots")).expect("workspace config dir");
+ fs::create_dir_all(user_home.join(".radroots/config/apps/cli")).expect("app config dir");
+ fs::write(
+ workspace_root.join(".radroots/config.toml"),
+ r#"
+[[capability_binding]]
+capability = "write_plane.trade_jsonrpc"
+provider = "hyf"
+target_kind = "explicit_endpoint"
+target = "https://rpc.workspace.test/jsonrpc"
+"#,
+ )
+ .expect("write workspace config");
+
+ let env = MapEnvironment {
+ values: BTreeMap::new(),
+ current_dir: workspace_root,
+ path_resolver: RadrootsPathResolver::new(
+ RadrootsPlatform::Linux,
+ RadrootsHostEnvironment {
+ home_dir: Some(user_home),
+ ..RadrootsHostEnvironment::default()
+ },
+ ),
+ };
+ let args = CliArgs::parse_from(["radroots", "config", "show"]);
+
+ let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
+ .expect_err("invalid capability binding provider");
+ assert!(
+ error
+ .to_string()
+ .contains("must use provider `radrootsd`, got `hyf`")
+ );
+ }
+
+ #[test]
fn invalid_relay_url_fails() {
let args = CliArgs::parse_from([
"radroots",
diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs
@@ -88,6 +88,15 @@ fn runtime_show_command_in(workdir: &Path) -> Command {
command
}
+fn binding_by_capability<'a>(json: &'a Value, capability_id: &str) -> &'a Value {
+ json["capability_bindings"]
+ .as_array()
+ .expect("capability bindings array")
+ .iter()
+ .find(|binding| binding["capability_id"] == capability_id)
+ .expect("binding present")
+}
+
#[test]
fn config_show_json_reports_default_bootstrap_state() {
let dir = tempdir().expect("tempdir");
@@ -272,6 +281,31 @@ fn config_show_json_reports_default_bootstrap_state() {
assert_eq!(json["myc"]["executable"], "myc");
assert_eq!(json["rpc"]["url"], "http://127.0.0.1:7070");
assert_eq!(json["rpc"]["bridge_auth_configured"], false);
+ assert_eq!(
+ json["capability_bindings"]
+ .as_array()
+ .expect("capability bindings")
+ .len(),
+ 4
+ );
+ let signer = binding_by_capability(&json, "signer.remote_nip46");
+ assert_eq!(signer["provider_runtime_id"], "myc");
+ assert_eq!(signer["binding_model"], "session_authorized_remote_signer");
+ assert_eq!(signer["state"], "disabled");
+ assert_eq!(signer["source"], "independent local signer mode");
+ let write = binding_by_capability(&json, "write_plane.trade_jsonrpc");
+ assert_eq!(write["provider_runtime_id"], "radrootsd");
+ assert_eq!(write["binding_model"], "daemon_backed_jsonrpc");
+ assert_eq!(write["state"], "not_configured");
+ let workflow = binding_by_capability(&json, "workflow.trade");
+ assert_eq!(workflow["provider_runtime_id"], "rhi");
+ assert_eq!(workflow["binding_model"], "out_of_process_worker");
+ assert_eq!(workflow["state"], "not_configured");
+ let inference = binding_by_capability(&json, "inference.hyf_stdio");
+ assert_eq!(inference["provider_runtime_id"], "hyf");
+ assert_eq!(inference["binding_model"], "stdio_service");
+ assert_eq!(inference["state"], "disabled");
+ assert_eq!(inference["source"], "hyf disabled by config");
}
#[test]
@@ -534,6 +568,91 @@ fn config_show_reads_workspace_rpc_config() {
}
#[test]
+fn config_show_reports_explicit_capability_bindings() {
+ let dir = tempdir().expect("tempdir");
+ let workspace_config_dir = dir.path().join(".radroots");
+ let user_config_dir = config_root(dir.path()).join("apps/cli");
+ fs::create_dir_all(&workspace_config_dir).expect("workspace config dir");
+ fs::create_dir_all(&user_config_dir).expect("user config dir");
+ fs::write(
+ workspace_config_dir.join("config.toml"),
+ r#"
+[[capability_binding]]
+capability = "write_plane.trade_jsonrpc"
+provider = "radrootsd"
+target_kind = "explicit_endpoint"
+target = "https://rpc.workspace.test/jsonrpc"
+
+[[capability_binding]]
+capability = "inference.hyf_stdio"
+provider = "hyf"
+target_kind = "managed_instance"
+target = "workspace-hyf"
+"#,
+ )
+ .expect("write workspace config");
+ fs::write(
+ user_config_dir.join("config.toml"),
+ r#"
+[[capability_binding]]
+capability = "signer.remote_nip46"
+provider = "myc"
+target_kind = "managed_instance"
+target = "default"
+managed_account_ref = "acct_demo"
+signer_session_ref = "session_demo"
+
+[[capability_binding]]
+capability = "workflow.trade"
+provider = "rhi"
+target_kind = "managed_instance"
+target = "workflow-default"
+
+[[capability_binding]]
+capability = "inference.hyf_stdio"
+provider = "hyf"
+target_kind = "explicit_endpoint"
+target = "bin/hyfd-user"
+"#,
+ )
+ .expect("write user config");
+
+ let output = runtime_show_command_in(dir.path())
+ .args(["--json", "config", "show"])
+ .output()
+ .expect("run config show");
+
+ assert!(output.status.success());
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output");
+
+ let signer = binding_by_capability(&json, "signer.remote_nip46");
+ assert_eq!(signer["state"], "configured");
+ assert_eq!(signer["source"], "user config [[capability_binding]]");
+ assert_eq!(signer["target_kind"], "managed_instance");
+ assert_eq!(signer["target"], "default");
+ assert_eq!(signer["managed_account_ref"], "acct_demo");
+ assert_eq!(signer["signer_session_ref"], "session_demo");
+
+ let write = binding_by_capability(&json, "write_plane.trade_jsonrpc");
+ assert_eq!(write["state"], "configured");
+ assert_eq!(write["source"], "workspace config [[capability_binding]]");
+ assert_eq!(write["target_kind"], "explicit_endpoint");
+ assert_eq!(write["target"], "https://rpc.workspace.test/jsonrpc");
+
+ let workflow = binding_by_capability(&json, "workflow.trade");
+ assert_eq!(workflow["state"], "configured");
+ assert_eq!(workflow["source"], "user config [[capability_binding]]");
+ assert_eq!(workflow["target_kind"], "managed_instance");
+ assert_eq!(workflow["target"], "workflow-default");
+
+ let inference = binding_by_capability(&json, "inference.hyf_stdio");
+ assert_eq!(inference["state"], "configured");
+ assert_eq!(inference["source"], "user config [[capability_binding]]");
+ assert_eq!(inference["target_kind"], "explicit_endpoint");
+ assert_eq!(inference["target"], "bin/hyfd-user");
+}
+
+#[test]
fn config_show_rejects_ndjson_for_singular_output() {
let dir = tempdir().expect("tempdir");
let output = runtime_show_command_in(dir.path())