cli

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

commit 2ccd369dd5073e1e11f46faa78378e8c08eee067
parent ba7c536e00a0775e279eafcdca36d7597b6deb37
Author: triesap <tyson@radroots.org>
Date:   Fri, 10 Apr 2026 20:13:11 +0000

runtime: make write-plane bindings authoritative

Diffstat:
MCargo.lock | 11+++++++++++
MCargo.toml | 1+
Msrc/runtime/daemon.rs | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/runtime/provider.rs | 473+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mtests/listing.rs | 189++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mtests/order.rs | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mtests/runtime_show.rs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
7 files changed, 822 insertions(+), 110 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1658,6 +1658,7 @@ dependencies = [ "radroots_protected_store", "radroots_replica_db", "radroots_replica_sync", + "radroots_runtime_manager", "radroots_runtime_paths", "radroots_secret_vault", "radroots_sql_core", @@ -1844,6 +1845,16 @@ dependencies = [ ] [[package]] +name = "radroots_runtime_manager" +version = "0.1.0-alpha.1" +dependencies = [ + "radroots_runtime_paths", + "serde", + "thiserror 1.0.69", + "toml", +] + +[[package]] name = "radroots_runtime_paths" version = "0.1.0-alpha.1" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -31,6 +31,7 @@ radroots_nostr_signer = { path = "../lib/crates/nostr_signer" } radroots_protected_store = { path = "../lib/crates/protected_store", features = ["std"] } radroots_replica_db = { path = "../lib/crates/replica_db" } radroots_replica_sync = { path = "../lib/crates/replica_sync" } +radroots_runtime_manager = { path = "../lib/crates/runtime_manager" } radroots_runtime_paths = { path = "../lib/crates/runtime_paths" } radroots_secret_vault = { path = "../lib/crates/secret_vault", features = ["std", "os-keyring"] } radroots_sql_core = { path = "../lib/crates/sql_core", features = ["native"] } diff --git a/src/runtime/daemon.rs b/src/runtime/daemon.rs @@ -12,6 +12,7 @@ use crate::domain::runtime::{ RpcStatusView, }; use crate::runtime::config::RuntimeConfig; +use crate::runtime::provider; const RPC_SOURCE: &str = "daemon rpc · durable write plane"; const BRIDGE_SOURCE: &str = "daemon bridge · durable write plane"; @@ -34,6 +35,12 @@ enum RpcAuthMode { BridgeBearer, } +#[derive(Debug, Clone)] +struct RpcTarget { + url: String, + bridge_bearer_token: Option<String>, +} + #[derive(Debug, Serialize)] struct JsonRpcRequest<'a> { jsonrpc: &'static str, @@ -380,8 +387,9 @@ pub fn bridge_listing_publish( idempotency_key: Option<&str>, signer_session_id: Option<&str>, ) -> Result<BridgeListingPublishResult, DaemonRpcError> { + let target = actor_write_target(config)?; let response: BridgePublishResponseRemote = call( - config, + &target, "bridge.listing.publish", Some(serde_json::json!({ "listing": listing, @@ -410,8 +418,9 @@ pub fn bridge_order_request( idempotency_key: Option<&str>, signer_session_id: Option<&str>, ) -> Result<BridgeOrderRequestResult, DaemonRpcError> { + let target = actor_write_target(config)?; let response: BridgePublishResponseRemote = call( - config, + &target, "bridge.order.request", Some(serde_json::json!({ "order": order, @@ -433,11 +442,21 @@ pub fn bridge_order_request( } fn bridge_status(config: &RuntimeConfig) -> Result<BridgeStatusRemote, DaemonRpcError> { - call(config, "bridge.status", None, RpcAuthMode::BridgeBearer) + call( + &default_target(config), + "bridge.status", + None, + RpcAuthMode::BridgeBearer, + ) } fn bridge_jobs(config: &RuntimeConfig) -> Result<Vec<BridgeJobRemote>, DaemonRpcError> { - call(config, "bridge.job.list", None, RpcAuthMode::BridgeBearer) + call( + &default_target(config), + "bridge.job.list", + None, + RpcAuthMode::BridgeBearer, + ) } fn bridge_job_status( @@ -445,7 +464,7 @@ fn bridge_job_status( job_id: &str, ) -> Result<BridgeJobRemote, DaemonRpcError> { call( - config, + &default_target(config), "bridge.job.status", Some(serde_json::json!({ "job_id": job_id })), RpcAuthMode::BridgeBearer, @@ -453,7 +472,29 @@ fn bridge_job_status( } fn nip46_sessions(config: &RuntimeConfig) -> Result<Vec<Nip46SessionRemote>, DaemonRpcError> { - call(config, "nip46.session.list", None, RpcAuthMode::None) + nip46_sessions_with_target(&default_target(config)) +} + +fn nip46_sessions_with_target( + target: &RpcTarget, +) -> Result<Vec<Nip46SessionRemote>, DaemonRpcError> { + call(target, "nip46.session.list", None, RpcAuthMode::None) +} + +fn actor_write_target(config: &RuntimeConfig) -> Result<RpcTarget, DaemonRpcError> { + let resolved = + provider::resolve_actor_write_plane_target(config).map_err(DaemonRpcError::Unconfigured)?; + Ok(RpcTarget { + url: resolved.url, + bridge_bearer_token: Some(resolved.bridge_bearer_token), + }) +} + +fn default_target(config: &RuntimeConfig) -> RpcTarget { + RpcTarget { + url: config.rpc.url.clone(), + bridge_bearer_token: config.rpc.bridge_bearer_token.clone(), + } } pub fn resolve_signer_session_id( @@ -463,7 +504,8 @@ pub fn resolve_signer_session_id( event_kind: u32, requested_session_id: Option<&str>, ) -> Result<String, DaemonRpcError> { - let sessions = nip46_sessions(config)?; + let target = actor_write_target(config)?; + let sessions = nip46_sessions_with_target(&target)?; if let Some(session_id) = requested_session_id { let Some(session) = sessions @@ -540,7 +582,7 @@ fn sign_event_allowed(perms: &[String], kind: u32) -> bool { } fn call<T: DeserializeOwned>( - config: &RuntimeConfig, + target: &RpcTarget, method: &str, params: Option<Value>, auth_mode: RpcAuthMode, @@ -550,7 +592,7 @@ fn call<T: DeserializeOwned>( .build() .map_err(|error| DaemonRpcError::InvalidResponse(format!("build rpc client: {error}")))?; - let mut request = client.post(config.rpc.url.as_str()).json(&JsonRpcRequest { + let mut request = client.post(target.url.as_str()).json(&JsonRpcRequest { jsonrpc: "2.0", id: 1, method, @@ -558,7 +600,7 @@ fn call<T: DeserializeOwned>( }); if matches!(auth_mode, RpcAuthMode::BridgeBearer) { - let Some(token) = config.rpc.bridge_bearer_token.as_deref() else { + let Some(token) = target.bridge_bearer_token.as_deref() else { return Err(DaemonRpcError::Unconfigured( "bridge bearer token is not configured".to_owned(), )); @@ -569,7 +611,7 @@ fn call<T: DeserializeOwned>( let response = request.send().map_err(|error| { DaemonRpcError::External(format!( "failed to reach daemon rpc at {}: {error}", - config.rpc.url + target.url )) })?; let status = response.status(); diff --git a/src/runtime/provider.rs b/src/runtime/provider.rs @@ -1,6 +1,13 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use radroots_runtime_manager::{ManagedRuntimeInstallState, load_registry}; +use serde::Deserialize; +use url::Url; + use crate::runtime::config::{ - CapabilityBindingInspection, CapabilityBindingInspectionState, RuntimeConfig, - INFERENCE_HYF_STDIO_CAPABILITY, WORKFLOW_TRADE_CAPABILITY, + CapabilityBindingInspection, CapabilityBindingInspectionState, CapabilityBindingTargetKind, + INFERENCE_HYF_STDIO_CAPABILITY, RuntimeConfig, WORKFLOW_TRADE_CAPABILITY, WRITE_PLANE_TRADE_JSONRPC_CAPABILITY, }; use crate::runtime::hyf; @@ -52,6 +59,12 @@ pub struct WritePlaneProviderView { } #[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedWritePlaneTarget { + pub url: String, + pub bridge_bearer_token: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkflowProviderView { pub provider_runtime_id: String, pub binding_model: String, @@ -96,18 +109,27 @@ pub struct HyfProviderView { pub deterministic_available: Option<bool>, } +#[derive(Debug, Clone, PartialEq, Eq)] +enum WritePlaneResolution { + Ready { + target: ResolvedWritePlaneTarget, + view: WritePlaneProviderView, + }, + Unconfigured(WritePlaneProviderView), +} + pub fn resolve_write_plane_provider(config: &RuntimeConfig) -> WritePlaneProviderView { - let _binding = inspect_binding(config, WRITE_PLANE_TRADE_JSONRPC_CAPABILITY); - WritePlaneProviderView { - provider_runtime_id: "radrootsd".to_owned(), - binding_model: "daemon_backed_jsonrpc".to_owned(), - state: "configured".to_owned(), - provenance: ProviderProvenance::DirectConfig.as_str().to_owned(), - source: "raw rpc config resolves the current write plane".to_owned(), - target_kind: None, - target: Some(config.rpc.url.clone()), - detail: "actor-authored durable writes still resolve through rpc.url until authoritative write-plane binding resolution lands".to_owned(), - bridge_auth_configured: config.rpc.bridge_bearer_token.is_some(), + match resolve_write_plane_resolution(config) { + WritePlaneResolution::Ready { view, .. } | WritePlaneResolution::Unconfigured(view) => view, + } +} + +pub fn resolve_actor_write_plane_target( + config: &RuntimeConfig, +) -> Result<ResolvedWritePlaneTarget, String> { + match resolve_write_plane_resolution(config) { + WritePlaneResolution::Ready { target, .. } => Ok(target), + WritePlaneResolution::Unconfigured(view) => Err(view.detail), } } @@ -206,6 +228,300 @@ pub fn resolve_capability_providers(config: &RuntimeConfig) -> Vec<ResolvedProvi ] } +fn resolve_write_plane_resolution(config: &RuntimeConfig) -> WritePlaneResolution { + if let Some(binding) = config.capability_binding(WRITE_PLANE_TRADE_JSONRPC_CAPABILITY) { + return resolve_bound_write_plane(config, binding); + } + + match resolve_managed_write_plane_instance(config, "local") { + Ok(target) => WritePlaneResolution::Ready { + view: WritePlaneProviderView { + provider_runtime_id: "radrootsd".to_owned(), + binding_model: "daemon_backed_jsonrpc".to_owned(), + state: "configured".to_owned(), + provenance: ProviderProvenance::ManagedDefault.as_str().to_owned(), + source: "managed preferred radrootsd instance".to_owned(), + target_kind: Some("managed_instance".to_owned()), + target: Some("local".to_owned()), + detail: format!( + "actor-authored durable writes resolve through managed radrootsd instance `local` at {}", + target.url + ), + bridge_auth_configured: true, + }, + target, + }, + Err(reason) => WritePlaneResolution::Unconfigured(WritePlaneProviderView { + provider_runtime_id: "radrootsd".to_owned(), + binding_model: "daemon_backed_jsonrpc".to_owned(), + state: "unconfigured".to_owned(), + provenance: ProviderProvenance::Unavailable.as_str().to_owned(), + source: "no explicit capability binding or managed preferred default".to_owned(), + target_kind: None, + target: None, + detail: reason, + bridge_auth_configured: false, + }), + } +} + +fn resolve_bound_write_plane( + config: &RuntimeConfig, + binding: &crate::runtime::config::CapabilityBindingConfig, +) -> WritePlaneResolution { + match binding.target_kind { + CapabilityBindingTargetKind::ExplicitEndpoint => { + let target_url = match validate_write_plane_url(binding.target.as_str()) { + Ok(url) => url, + Err(reason) => { + return WritePlaneResolution::Unconfigured(WritePlaneProviderView { + provider_runtime_id: "radrootsd".to_owned(), + binding_model: "daemon_backed_jsonrpc".to_owned(), + state: "unconfigured".to_owned(), + provenance: ProviderProvenance::ExplicitBinding.as_str().to_owned(), + source: binding.source.as_str().to_owned(), + target_kind: Some(binding.target_kind.as_str().to_owned()), + target: Some(binding.target.clone()), + detail: reason, + bridge_auth_configured: false, + }); + } + }; + let Some(bridge_bearer_token) = config + .rpc + .bridge_bearer_token + .as_deref() + .map(str::trim) + .filter(|token| !token.is_empty()) + .map(ToOwned::to_owned) + else { + return WritePlaneResolution::Unconfigured(WritePlaneProviderView { + provider_runtime_id: "radrootsd".to_owned(), + binding_model: "daemon_backed_jsonrpc".to_owned(), + state: "unconfigured".to_owned(), + provenance: ProviderProvenance::ExplicitBinding.as_str().to_owned(), + source: binding.source.as_str().to_owned(), + target_kind: Some(binding.target_kind.as_str().to_owned()), + target: Some(binding.target.clone()), + detail: + "explicit write-plane capability bindings require RADROOTS_RPC_BEARER_TOKEN for actor-authored durable writes" + .to_owned(), + bridge_auth_configured: false, + }); + }; + WritePlaneResolution::Ready { + view: WritePlaneProviderView { + provider_runtime_id: "radrootsd".to_owned(), + binding_model: "daemon_backed_jsonrpc".to_owned(), + state: "configured".to_owned(), + provenance: ProviderProvenance::ExplicitBinding.as_str().to_owned(), + source: binding.source.as_str().to_owned(), + target_kind: Some(binding.target_kind.as_str().to_owned()), + target: Some(target_url.clone()), + detail: format!( + "actor-authored durable writes resolve through explicit write-plane endpoint {}", + target_url + ), + bridge_auth_configured: true, + }, + target: ResolvedWritePlaneTarget { + url: target_url, + bridge_bearer_token, + }, + } + } + CapabilityBindingTargetKind::ManagedInstance => { + match resolve_managed_write_plane_instance(config, binding.target.as_str()) { + Ok(target) => WritePlaneResolution::Ready { + view: WritePlaneProviderView { + provider_runtime_id: "radrootsd".to_owned(), + binding_model: "daemon_backed_jsonrpc".to_owned(), + state: "configured".to_owned(), + provenance: ProviderProvenance::ManagedDefault.as_str().to_owned(), + source: binding.source.as_str().to_owned(), + target_kind: Some(binding.target_kind.as_str().to_owned()), + target: Some(binding.target.clone()), + detail: format!( + "actor-authored durable writes resolve through managed radrootsd instance `{}` at {}", + binding.target, target.url + ), + bridge_auth_configured: true, + }, + target, + }, + Err(reason) => WritePlaneResolution::Unconfigured(WritePlaneProviderView { + provider_runtime_id: "radrootsd".to_owned(), + binding_model: "daemon_backed_jsonrpc".to_owned(), + state: "unconfigured".to_owned(), + provenance: ProviderProvenance::ManagedDefault.as_str().to_owned(), + source: binding.source.as_str().to_owned(), + target_kind: Some(binding.target_kind.as_str().to_owned()), + target: Some(binding.target.clone()), + detail: reason, + bridge_auth_configured: false, + }), + } + } + } +} + +fn resolve_managed_write_plane_instance( + config: &RuntimeConfig, + instance_id: &str, +) -> Result<ResolvedWritePlaneTarget, String> { + let registry_path = runtime_manager_registry_path(config)?; + let registry = load_registry(&registry_path).map_err(|err| { + format!( + "load runtime-manager registry {}: {err}", + registry_path.display() + ) + })?; + let Some(record) = registry + .instances + .iter() + .find(|record| record.runtime_id == "radrootsd" && record.instance_id == instance_id) + else { + return Err(format!( + "actor-authored durable writes require an explicit write-plane capability binding or managed radrootsd instance `{instance_id}` in {}", + registry_path.display() + )); + }; + if record.install_state != ManagedRuntimeInstallState::Configured { + return Err(format!( + "managed radrootsd instance `{instance_id}` is not configured in {}", + registry_path.display() + )); + } + load_managed_radrootsd_target(record.config_path.as_path(), instance_id) +} + +fn runtime_manager_registry_path(config: &RuntimeConfig) -> Result<PathBuf, String> { + let Some(app_dir) = config.paths.app_config_path.parent() else { + return Err("resolve cli app config directory for runtime-manager lookup".to_owned()); + }; + let Some(apps_dir) = app_dir.parent() else { + return Err("resolve cli apps config root for runtime-manager lookup".to_owned()); + }; + let Some(config_root) = apps_dir.parent() else { + return Err("resolve cli config root for runtime-manager lookup".to_owned()); + }; + Ok(config_root.join("shared/runtime-manager/instances.toml")) +} + +#[derive(Debug, Deserialize)] +struct ManagedRadrootsdSettingsFile { + config: ManagedRadrootsdConfigFile, +} + +#[derive(Debug, Deserialize, Default)] +struct ManagedRadrootsdConfigFile { + #[serde(default)] + rpc: ManagedRadrootsdRpcConfig, + #[serde(default)] + rpc_addr: Option<String>, + #[serde(default)] + bridge: ManagedRadrootsdBridgeConfig, +} + +#[derive(Debug, Deserialize)] +struct ManagedRadrootsdRpcConfig { + #[serde(default = "default_managed_radrootsd_rpc_addr")] + addr: String, +} + +impl Default for ManagedRadrootsdRpcConfig { + fn default() -> Self { + Self { + addr: default_managed_radrootsd_rpc_addr(), + } + } +} + +#[derive(Debug, Deserialize, Default)] +struct ManagedRadrootsdBridgeConfig { + #[serde(default)] + enabled: bool, + #[serde(default)] + bearer_token: Option<String>, +} + +fn default_managed_radrootsd_rpc_addr() -> String { + "127.0.0.1:7070".to_owned() +} + +fn load_managed_radrootsd_target( + config_path: &Path, + instance_id: &str, +) -> Result<ResolvedWritePlaneTarget, String> { + let raw = fs::read_to_string(config_path).map_err(|err| { + format!( + "read managed radrootsd config for instance `{instance_id}` at {}: {err}", + config_path.display() + ) + })?; + let settings: ManagedRadrootsdSettingsFile = toml::from_str(raw.as_str()).map_err(|err| { + format!( + "parse managed radrootsd config for instance `{instance_id}` at {}: {err}", + config_path.display() + ) + })?; + if !settings.config.bridge.enabled { + return Err(format!( + "managed radrootsd instance `{instance_id}` has bridge ingress disabled in {}", + config_path.display() + )); + } + let Some(bridge_bearer_token) = settings + .config + .bridge + .bearer_token + .as_deref() + .map(str::trim) + .filter(|token| !token.is_empty()) + .map(ToOwned::to_owned) + else { + return Err(format!( + "managed radrootsd instance `{instance_id}` is missing bridge bearer_token in {}", + config_path.display() + )); + }; + let rpc_addr = settings + .config + .rpc_addr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(settings.config.rpc.addr.as_str()); + let url = rpc_addr_to_url(rpc_addr)?; + Ok(ResolvedWritePlaneTarget { + url, + bridge_bearer_token, + }) +} + +fn rpc_addr_to_url(value: &str) -> Result<String, String> { + let trimmed = value.trim(); + if trimmed.contains("://") { + return validate_write_plane_url(trimmed); + } + validate_write_plane_url(format!("http://{trimmed}").as_str()) +} + +fn validate_write_plane_url(value: &str) -> Result<String, String> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("write-plane endpoint must not be empty".to_owned()); + } + let parsed = Url::parse(trimmed) + .map_err(|err| format!("write-plane endpoint `{trimmed}` is invalid: {err}"))?; + if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { + return Err(format!( + "write-plane endpoint must use http or https, got `{trimmed}`" + )); + } + Ok(trimmed.to_owned()) +} + fn inspect_binding(config: &RuntimeConfig, capability_id: &str) -> CapabilityBindingInspection { config .inspect_capability_bindings() @@ -256,7 +572,10 @@ fn hyf_executable( if binding.state == CapabilityBindingInspectionState::Configured && binding.target_kind.as_deref() == Some("explicit_endpoint") { - return binding.target.clone().unwrap_or_else(|| status.executable.clone()); + return binding + .target + .clone() + .unwrap_or_else(|| status.executable.clone()); } if !config.hyf.enabled { return status.executable.clone(); @@ -266,14 +585,16 @@ fn hyf_executable( #[cfg(test)] mod tests { - use std::path::PathBuf; + use std::fs; + use std::path::{Path, PathBuf}; use radroots_runtime_paths::RadrootsMigrationReport; use radroots_secret_vault::RadrootsSecretBackend; + use tempfile::tempdir; use super::{ - ProviderProvenance, resolve_capability_providers, resolve_hyf_provider, - resolve_workflow_provider, resolve_write_plane_provider, + ProviderProvenance, resolve_actor_write_plane_target, resolve_capability_providers, + resolve_hyf_provider, resolve_workflow_provider, resolve_write_plane_provider, }; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, CapabilityBindingConfig, @@ -302,8 +623,8 @@ mod tests { app_namespace: "apps/cli".into(), shared_accounts_namespace: "shared/accounts".into(), shared_identities_namespace: "shared/identities".into(), - app_config_path: PathBuf::from("/tmp/config.toml"), - workspace_config_path: PathBuf::from("/tmp/workspace-config.toml"), + app_config_path: PathBuf::from("/tmp/config/apps/cli/config.toml"), + workspace_config_path: PathBuf::from("/tmp/workspace/.radroots/config.toml"), app_data_root: PathBuf::from("/tmp/data"), app_logs_root: PathBuf::from("/tmp/logs"), shared_accounts_data_root: PathBuf::from("/tmp/shared/accounts"), @@ -365,10 +686,11 @@ mod tests { } #[test] - fn write_plane_uses_direct_config_provenance() { + fn write_plane_requires_authoritative_binding_or_managed_default() { let view = resolve_write_plane_provider(&sample_config(Vec::new(), false)); - assert_eq!(view.provenance, ProviderProvenance::DirectConfig.as_str()); - assert_eq!(view.target.as_deref(), Some("http://127.0.0.1:7070")); + assert_eq!(view.state, "unconfigured"); + assert_eq!(view.provenance, ProviderProvenance::Unavailable.as_str()); + assert!(view.target.is_none()); } #[test] @@ -384,11 +706,90 @@ mod tests { signer_session_ref: None, }; let view = resolve_workflow_provider(&sample_config(vec![binding], false)); - assert_eq!(view.provenance, ProviderProvenance::ExplicitBinding.as_str()); + assert_eq!( + view.provenance, + ProviderProvenance::ExplicitBinding.as_str() + ); assert_eq!(view.target_kind.as_deref(), Some("explicit_endpoint")); } #[test] + fn explicit_write_plane_binding_requires_bridge_bearer_auth() { + let binding = CapabilityBindingConfig { + capability_id: "write_plane.trade_jsonrpc".into(), + provider_runtime_id: "radrootsd".into(), + binding_model: "daemon_backed_jsonrpc".into(), + source: CapabilityBindingSource::WorkspaceConfig, + target_kind: CapabilityBindingTargetKind::ExplicitEndpoint, + target: "https://rpc.workspace.test".into(), + managed_account_ref: None, + signer_session_ref: None, + }; + let view = resolve_write_plane_provider(&sample_config(vec![binding], false)); + assert_eq!(view.state, "unconfigured"); + assert_eq!( + view.provenance, + ProviderProvenance::ExplicitBinding.as_str() + ); + assert_eq!(view.target.as_deref(), Some("https://rpc.workspace.test")); + } + + #[test] + fn managed_default_write_plane_uses_runtime_manager_registry() { + let dir = tempdir().expect("tempdir"); + let config_dir = dir.path().join("config"); + let app_config_path = config_dir.join("apps/cli/config.toml"); + fs::create_dir_all(app_config_path.parent().expect("app config parent")) + .expect("create app config dir"); + fs::write(&app_config_path, "").expect("write app config"); + + let registry_path = config_dir.join("shared/runtime-manager/instances.toml"); + fs::create_dir_all(registry_path.parent().expect("registry parent")) + .expect("create registry parent"); + let managed_config_path = dir.path().join("radrootsd-config.toml"); + write_managed_radrootsd_config( + managed_config_path.as_path(), + "127.0.0.1:7444", + "managed-bridge-token", + ); + fs::write( + &registry_path, + format!( + r#"schema = "radroots_runtime-instance-registry" +schema_version = 1 + +[[instances]] +runtime_id = "radrootsd" +instance_id = "local" +management_mode = "interactive_user_managed" +install_state = "configured" +binary_path = "/tmp/radrootsd" +config_path = "{}" +logs_path = "/tmp/logs" +run_path = "/tmp/run" +installed_version = "0.1.0" +"#, + managed_config_path.display() + ), + ) + .expect("write registry"); + + let mut config = sample_config(Vec::new(), false); + config.paths.app_config_path = app_config_path; + + let view = resolve_write_plane_provider(&config); + assert_eq!(view.state, "configured"); + assert_eq!(view.provenance, ProviderProvenance::ManagedDefault.as_str()); + assert_eq!(view.target_kind.as_deref(), Some("managed_instance")); + assert_eq!(view.target.as_deref(), Some("local")); + + let target = + resolve_actor_write_plane_target(&config).expect("resolve actor write plane target"); + assert_eq!(target.url, "http://127.0.0.1:7444"); + assert_eq!(target.bridge_bearer_token, "managed-bridge-token"); + } + + #[test] fn hyf_uses_direct_config_when_enabled_without_binding() { let view = resolve_hyf_provider(&sample_config(Vec::new(), true)); assert_eq!(view.provenance, ProviderProvenance::DirectConfig.as_str()); @@ -410,7 +811,10 @@ mod tests { }; let view = resolve_hyf_provider(&sample_config(vec![binding], false)); assert_eq!(view.state, "disabled"); - assert_eq!(view.provenance, ProviderProvenance::ExplicitBinding.as_str()); + assert_eq!( + view.provenance, + ProviderProvenance::ExplicitBinding.as_str() + ); assert_eq!(view.source, "user config [[capability_binding]]"); assert_eq!(view.target_kind.as_deref(), Some("explicit_endpoint")); assert_eq!(view.target.as_deref(), Some("bin/hyfd-user")); @@ -425,4 +829,25 @@ mod tests { assert_eq!(providers[1].capability_id, "workflow.trade"); assert_eq!(providers[2].capability_id, "inference.hyf_stdio"); } + + fn write_managed_radrootsd_config(path: &Path, rpc_addr: &str, bearer_token: &str) { + fs::write( + path, + format!( + r#"[metadata] +name = "managed-radrootsd" + +[config] + +[config.rpc] +addr = "{rpc_addr}" + +[config.bridge] +enabled = true +bearer_token = "{bearer_token}" +"# + ), + ) + .expect("write managed radrootsd config"); + } } diff --git a/tests/listing.rs b/tests/listing.rs @@ -62,6 +62,26 @@ fn write_workspace_config(workdir: &Path, contents: &str) { fs::write(config_dir.join("config.toml"), contents).expect("write workspace config"); } +fn workspace_config_with_write_plane(extra: &str, url: &str) -> String { + let mut rendered = String::new(); + if !extra.trim().is_empty() { + rendered.push_str(extra.trim()); + rendered.push_str("\n\n"); + } + rendered.push_str( + format!( + r#"[[capability_binding]] +capability = "write_plane.trade_jsonrpc" +provider = "radrootsd" +target_kind = "explicit_endpoint" +target = "{url}" +"# + ) + .as_str(), + ); + rendered +} + fn write_fake_myc(dir: &Path, script: &str) -> std::path::PathBuf { let path = dir.join("fake-myc"); fs::write(&path, script).expect("write fake myc"); @@ -396,9 +416,12 @@ fn listing_publish_and_update_use_durable_bridge_publish() { other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")), } }); + write_workspace_config( + dir.path(), + workspace_config_with_write_plane("", server.url().as_str()).as_str(), + ); let publish_output = cli_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") .args([ "--json", @@ -437,7 +460,6 @@ fn listing_publish_and_update_use_durable_bridge_publish() { ); let update_output = cli_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") .args([ "--json", @@ -545,9 +567,12 @@ fn listing_archive_and_dry_run_are_truthful() { other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")), } }); + write_workspace_config( + dir.path(), + workspace_config_with_write_plane("", server.url().as_str()).as_str(), + ); let archive_output = cli_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") .args([ "--json", @@ -665,20 +690,6 @@ fn listing_publish_uses_myc_binding_before_resolving_daemon_signer_session() { ) .expect("write listing draft"); - write_workspace_config( - dir.path(), - format!( - r#" -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "managed_instance" -target = "default" -managed_account_ref = "{account_id}" -"# - ) - .as_str(), - ); let myc = write_fake_myc( dir.path(), successful_status_script( @@ -718,9 +729,26 @@ managed_account_ref = "{account_id}" other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")), } }); + write_workspace_config( + dir.path(), + workspace_config_with_write_plane( + format!( + r#" +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "managed_instance" +target = "default" +managed_account_ref = "{account_id}" +"# + ) + .as_str(), + server.url().as_str(), + ) + .as_str(), + ); let output = cli_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") .args([ "--json", @@ -816,20 +844,6 @@ fn listing_publish_rejects_myc_binding_that_resolves_the_wrong_actor() { ) .expect("write listing draft"); - write_workspace_config( - dir.path(), - format!( - r#" -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "managed_instance" -target = "default" -managed_account_ref = "{mismatch_account_id}" -"# - ) - .as_str(), - ); let myc = write_fake_myc( dir.path(), successful_status_script( @@ -849,9 +863,26 @@ managed_account_ref = "{mismatch_account_id}" recorded.lock().expect("recorded").push(body.clone()); MockRpcResponse::rpc_error(-32601, "daemon write path should not be reached") }); + write_workspace_config( + dir.path(), + workspace_config_with_write_plane( + format!( + r#" +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "managed_instance" +target = "default" +managed_account_ref = "{mismatch_account_id}" +"# + ) + .as_str(), + server.url().as_str(), + ) + .as_str(), + ); let output = cli_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") .args([ "--json", @@ -942,9 +973,12 @@ fn listing_publish_without_matching_signer_session_exits_unconfigured() { other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")), } }); + write_workspace_config( + dir.path(), + workspace_config_with_write_plane("", server.url().as_str()).as_str(), + ); let publish_output = cli_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") .args([ "--json", @@ -1036,9 +1070,12 @@ fn listing_publish_rejects_requested_session_that_mismatches_seller_pubkey() { other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")), } }); + write_workspace_config( + dir.path(), + workspace_config_with_write_plane("", server.url().as_str()).as_str(), + ); let publish_output = cli_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") .args([ "--json", @@ -1066,6 +1103,88 @@ fn listing_publish_rejects_requested_session_that_mismatches_seller_pubkey() { assert_eq!(recorded[0]["method"], "nip46.session.list"); } +#[test] +fn listing_publish_requires_authoritative_write_plane_binding() { + let _guard = listing_test_guard(); + let dir = tempdir().expect("tempdir"); + let init = cli_command_in(dir.path()) + .args(["local", "init"]) + .output() + .expect("run local init"); + assert!(init.status.success()); + + let account_output = cli_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run account new"); + assert!(account_output.status.success()); + let account_json: Value = + serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); + let seller_pubkey = account_json["public_identity"]["public_key_hex"] + .as_str() + .expect("seller pubkey") + .to_owned(); + seed_farm( + dir.path(), + seller_pubkey.as_str(), + "AAAAAAAAAAAAAAAAAAAAAw", + "La Huerta", + ); + + let draft_path = dir.path().join("missing-write-binding.toml"); + fs::write( + &draft_path, + valid_listing_draft( + "AAAAAAAAAAAAAAAAAAAAAg", + "", + "", + "eggs", + "Pasture eggs", + "Protein", + "Fresh pasture-raised eggs collected daily.", + "12", + "each", + "4.50", + "USD", + "1", + "each", + "18", + "pickup", + "La Huerta del Sur", + ), + ) + .expect("write listing draft"); + + let requests = Arc::new(Mutex::new(Vec::<Value>::new())); + let recorded = Arc::clone(&requests); + let server = MockRpcServer::start(move |body, _auth_header| { + recorded.lock().expect("recorded").push(body); + MockRpcResponse::rpc_error(-32601, "daemon write path should not be reached") + }); + + let publish_output = cli_command_in(dir.path()) + .env("RADROOTS_RPC_URL", server.url()) + .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") + .args([ + "--json", + "listing", + "publish", + draft_path.to_str().expect("draft path"), + ]) + .output() + .expect("run listing publish"); + assert_eq!(publish_output.status.code(), Some(3)); + let publish_json: Value = + serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json"); + assert_eq!(publish_json["state"], "unconfigured"); + assert!( + publish_json["reason"].as_str().expect("reason").contains( + "explicit write-plane capability binding or managed radrootsd instance `local`" + ) + ); + assert!(requests.lock().expect("requests").is_empty()); +} + fn seed_farm(workdir: &Path, pubkey: &str, d_tag: &str, name: &str) { let replica_db = data_root(workdir).join("apps/cli/replica/replica.sqlite"); let executor = SqliteExecutor::open(&replica_db).expect("open replica db"); diff --git a/tests/order.rs b/tests/order.rs @@ -61,6 +61,26 @@ fn write_workspace_config(workdir: &Path, contents: &str) { fs::write(config_dir.join("config.toml"), contents).expect("write workspace config"); } +fn workspace_config_with_write_plane(extra: &str, url: &str) -> String { + let mut rendered = String::new(); + if !extra.trim().is_empty() { + rendered.push_str(extra.trim()); + rendered.push_str("\n\n"); + } + rendered.push_str( + format!( + r#"[[capability_binding]] +capability = "write_plane.trade_jsonrpc" +provider = "radrootsd" +target_kind = "explicit_endpoint" +target = "{url}" +"# + ) + .as_str(), + ); + rendered +} + fn write_fake_myc(dir: &Path, script: &str) -> std::path::PathBuf { let path = dir.join("fake-myc"); fs::write(&path, script).expect("write fake myc"); @@ -566,9 +586,12 @@ fn order_submit_persists_submission_metadata_and_reports_job() { other => panic!("unexpected mock rpc method {other}"), } }); + write_workspace_config( + dir.path(), + workspace_config_with_write_plane("", server.url().as_str()).as_str(), + ); let submit_output = order_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) .env("RADROOTS_RPC_BEARER_TOKEN", "test-token") .args([ "--json", @@ -728,20 +751,6 @@ fn order_submit_uses_myc_binding_before_resolving_daemon_signer_session() { .expect("buyer pubkey") .to_owned(); - write_workspace_config( - dir.path(), - format!( - r#" -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "managed_instance" -target = "default" -managed_account_ref = "{account_id}" -"# - ) - .as_str(), - ); let myc = write_fake_myc( dir.path(), successful_status_script( @@ -796,9 +805,26 @@ managed_account_ref = "{account_id}" other => panic!("unexpected mock rpc method {other}"), } }); + write_workspace_config( + dir.path(), + workspace_config_with_write_plane( + format!( + r#" +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "managed_instance" +target = "default" +managed_account_ref = "{account_id}" +"# + ) + .as_str(), + server.url().as_str(), + ) + .as_str(), + ); let submit_output = order_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) .env("RADROOTS_RPC_BEARER_TOKEN", "test-token") .args([ "--json", @@ -877,20 +903,6 @@ fn order_submit_rejects_myc_binding_that_resolves_the_wrong_actor() { .expect("mismatch account id"); let mismatch_public_identity = mismatch_account_json["public_identity"].clone(); - write_workspace_config( - dir.path(), - format!( - r#" -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "managed_instance" -target = "default" -managed_account_ref = "{mismatch_account_id}" -"# - ) - .as_str(), - ); let myc = write_fake_myc( dir.path(), successful_status_script( @@ -917,9 +929,26 @@ managed_account_ref = "{mismatch_account_id}" }); panic!("daemon write path should not be reached"); }); + write_workspace_config( + dir.path(), + workspace_config_with_write_plane( + format!( + r#" +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "managed_instance" +target = "default" +managed_account_ref = "{mismatch_account_id}" +"# + ) + .as_str(), + server.url().as_str(), + ) + .as_str(), + ); let submit_output = order_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) .env("RADROOTS_RPC_BEARER_TOKEN", "test-token") .args([ "--json", @@ -1009,9 +1038,12 @@ fn order_submit_without_unique_matching_signer_session_exits_unconfigured() { other => panic!("unexpected mock rpc method {other}"), } }); + write_workspace_config( + dir.path(), + workspace_config_with_write_plane("", server.url().as_str()).as_str(), + ); let submit_output = order_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) .env("RADROOTS_RPC_BEARER_TOKEN", "test-token") .args(["--json", "order", "submit", order_id]) .output() @@ -1082,9 +1114,12 @@ fn order_submit_rejects_requested_session_that_mismatches_buyer_pubkey() { other => panic!("unexpected mock rpc method {other}"), } }); + write_workspace_config( + dir.path(), + workspace_config_with_write_plane("", server.url().as_str()).as_str(), + ); let submit_output = order_command_in(dir.path()) - .env("RADROOTS_RPC_URL", server.url()) .env("RADROOTS_RPC_BEARER_TOKEN", "test-token") .args([ "--json", diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -30,6 +30,10 @@ fn config_root(workdir: &Path) -> std::path::PathBuf { } } +fn runtime_manager_registry_path(workdir: &Path) -> std::path::PathBuf { + config_root(workdir).join("shared/runtime-manager/instances.toml") +} + fn data_root(workdir: &Path) -> std::path::PathBuf { if cfg!(windows) { localappdata_root(workdir).join("data") @@ -280,14 +284,17 @@ fn config_show_json_reports_default_bootstrap_state() { ); assert_eq!(json["myc"]["executable"], "myc"); assert_eq!(json["write_plane"]["provider_runtime_id"], "radrootsd"); - assert_eq!(json["write_plane"]["binding_model"], "daemon_backed_jsonrpc"); - assert_eq!(json["write_plane"]["state"], "configured"); - assert_eq!(json["write_plane"]["provenance"], "direct_config"); + assert_eq!( + json["write_plane"]["binding_model"], + "daemon_backed_jsonrpc" + ); + assert_eq!(json["write_plane"]["state"], "unconfigured"); + assert_eq!(json["write_plane"]["provenance"], "unavailable"); assert_eq!( json["write_plane"]["source"], - "raw rpc config resolves the current write plane" + "no explicit capability binding or managed preferred default" ); - assert_eq!(json["write_plane"]["target"], "http://127.0.0.1:7070"); + assert!(json["write_plane"]["target"].is_null()); assert_eq!(json["write_plane"]["bridge_auth_configured"], false); assert_eq!(json["workflow"]["provider_runtime_id"], "rhi"); assert_eq!(json["workflow"]["binding_model"], "out_of_process_worker"); @@ -690,8 +697,18 @@ target = "bin/hyfd-user" .as_str() .is_some_and(|detail| detail.contains("do not imply")) ); - assert_eq!(json["write_plane"]["provenance"], "direct_config"); - assert_eq!(json["write_plane"]["target"], "http://127.0.0.1:7070"); + assert_eq!(json["write_plane"]["state"], "unconfigured"); + assert_eq!(json["write_plane"]["provenance"], "explicit_binding"); + assert_eq!( + json["write_plane"]["source"], + "workspace config [[capability_binding]]" + ); + assert_eq!(json["write_plane"]["target_kind"], "explicit_endpoint"); + assert_eq!( + json["write_plane"]["target"], + "https://rpc.workspace.test/jsonrpc" + ); + assert_eq!(json["write_plane"]["bridge_auth_configured"], false); assert_eq!(json["hyf_provider"]["provider_runtime_id"], "hyf"); assert_eq!(json["hyf_provider"]["provenance"], "explicit_binding"); assert_eq!(json["hyf_provider"]["target_kind"], "explicit_endpoint"); @@ -706,6 +723,68 @@ target = "bin/hyfd-user" } #[test] +fn config_show_uses_managed_default_write_plane_when_local_instance_exists() { + let dir = tempdir().expect("tempdir"); + let registry_path = runtime_manager_registry_path(dir.path()); + fs::create_dir_all(registry_path.parent().expect("registry parent")).expect("registry dir"); + let managed_config_path = dir.path().join("managed-radrootsd.toml"); + fs::write( + &managed_config_path, + r#"[metadata] +name = "managed-radrootsd" + +[config] + +[config.rpc] +addr = "127.0.0.1:7444" + +[config.bridge] +enabled = true +bearer_token = "managed-bridge-token" +"#, + ) + .expect("write managed config"); + fs::write( + &registry_path, + format!( + r#"schema = "radroots_runtime-instance-registry" +schema_version = 1 + +[[instances]] +runtime_id = "radrootsd" +instance_id = "local" +management_mode = "interactive_user_managed" +install_state = "configured" +binary_path = "/tmp/radrootsd" +config_path = "{}" +logs_path = "/tmp/radrootsd/logs" +run_path = "/tmp/radrootsd/run" +installed_version = "0.1.0" +"#, + managed_config_path.display() + ), + ) + .expect("write managed registry"); + + 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"); + assert_eq!(json["write_plane"]["state"], "configured"); + assert_eq!(json["write_plane"]["provenance"], "managed_default"); + assert_eq!( + json["write_plane"]["source"], + "managed preferred radrootsd instance" + ); + assert_eq!(json["write_plane"]["target_kind"], "managed_instance"); + assert_eq!(json["write_plane"]["target"], "local"); + assert_eq!(json["write_plane"]["bridge_auth_configured"], true); +} + +#[test] fn config_show_rejects_ndjson_for_singular_output() { let dir = tempdir().expect("tempdir"); let output = runtime_show_command_in(dir.path())