cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

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:
Msrc/commands/doctor.rs | 27+++++++++++++++++++++++++++
Msrc/commands/runtime.rs | 23+++++++++++++++++++----
Msrc/domain/runtime.rs | 18++++++++++++++++++
Msrc/render/mod.rs | 41+++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/config.rs | 415++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/runtime_show.rs | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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())