cli

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

commit b159e1225161443c327cd0304c4620580e230505
parent 050f20d8ceae72d64cf8aff912a51624d6c05131
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 03:15:59 +0000

cli: enforce approval error metadata

- add a structured adapter approval error that maps to approval_required exit code 6
- enforce required account import and remove approvals outside dry-run paths
- update required approval services to use the shared adapter error
- cover approval metadata mapping across registry, core, seller, listing, and order tests

Diffstat:
Msrc/operation_adapter.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/operation_core.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/operation_farm.rs | 9+++++----
Msrc/operation_listing.rs | 9+++++----
Msrc/operation_order.rs | 9+++++----
5 files changed, 124 insertions(+), 18 deletions(-)

diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -7,7 +7,8 @@ use serde_json::{Map, Value}; use crate::operation_registry::{OPERATION_REGISTRY, OperationSpec, get_operation}; use crate::output_contract::{ - EnvelopeContext, NextAction, OUTPUT_SCHEMA_VERSION, OutputEnvelope, OutputWarning, + CliExitCode, EnvelopeContext, NextAction, OUTPUT_SCHEMA_VERSION, OutputEnvelope, OutputError, + OutputWarning, }; use crate::target_cli::{TargetCliArgs, TargetOutputFormat}; @@ -298,10 +299,55 @@ pub enum OperationAdapterError { operation_id: String, message: String, }, + #[error("approval required for `{operation_id}`: {message}")] + ApprovalRequired { + operation_id: String, + message: String, + }, #[error("operation runtime error: {0}")] Runtime(String), } +impl OperationAdapterError { + pub fn approval_required(operation_id: &str) -> Self { + Self::ApprovalRequired { + operation_id: operation_id.to_owned(), + message: "missing required `approval_token` input".to_owned(), + } + } + + pub fn to_output_error(&self) -> OutputError { + match self { + Self::ApprovalRequired { message, .. } => OutputError::new( + "approval_required", + message.clone(), + CliExitCode::ApprovalRequiredOrDenied, + ), + Self::InvalidInput { message, .. } => { + OutputError::new("invalid_input", message.clone(), CliExitCode::InvalidInput) + } + Self::UnknownOperation(operation_id) => OutputError::new( + "unknown_operation", + format!("unknown operation `{operation_id}`"), + CliExitCode::InvalidInput, + ), + Self::RequestTypeMismatch { .. } | Self::ResultTypeMismatch { .. } => OutputError::new( + "contract_mismatch", + self.to_string(), + CliExitCode::InternalError, + ), + Self::Serialization(message) => OutputError::new( + "serialization_failed", + message.clone(), + CliExitCode::InternalError, + ), + Self::Runtime(message) => { + OutputError::new("runtime_error", message.clone(), CliExitCode::InternalError) + } + } + } +} + macro_rules! mvp_operation_contracts { ($( $variant:ident => ($request:ident, $result:ident, $operation_id:literal) ),+ $(,)?) => { #[derive(Debug, Clone, PartialEq)] @@ -538,9 +584,9 @@ mod tests { use serde_json::json; use super::{ - MvpOperationRequest, OperationAdapter, OperationContext, OperationInputMode, - OperationNetworkMode, OperationOutputFormat, OperationRequest, OperationResult, - OperationService, WorkspaceGetRequest, WorkspaceGetResult, + MvpOperationRequest, OperationAdapter, OperationAdapterError, OperationContext, + OperationInputMode, OperationNetworkMode, OperationOutputFormat, OperationRequest, + OperationResult, OperationService, WorkspaceGetRequest, WorkspaceGetResult, adapter_registry_linkage_is_valid, }; use crate::operation_registry::OPERATION_REGISTRY; @@ -654,4 +700,14 @@ mod tests { assert_eq!(envelope.request_id, "req_test"); assert_eq!(envelope.result, json!({})); } + + #[test] + fn approval_errors_map_to_structured_exit_code() { + let error = OperationAdapterError::approval_required("order.submit"); + let output_error = error.to_output_error(); + + assert_eq!(output_error.code, "approval_required"); + assert_eq!(output_error.exit_code, 6); + assert!(output_error.message.contains("approval_token")); + } } diff --git a/src/operation_core.rs b/src/operation_core.rs @@ -228,6 +228,11 @@ impl OperationService<AccountImportRequest> for CoreOperationService<'_> { "default": make_default, })); } + if request.context.approval_token.is_none() { + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); + } let account = map_runtime(import_public_identity( self.config, @@ -304,6 +309,11 @@ impl OperationService<AccountRemoveRequest> for CoreOperationService<'_> { "selector": selector, })); } + if request.context.approval_token.is_none() { + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); + } let result = map_runtime(remove_account(self.config, selector.as_str()))?; json_operation_result::<AccountRemoveResult>(json!({ @@ -556,12 +566,14 @@ mod tests { use radroots_runtime_paths::RadrootsMigrationReport; use radroots_secret_vault::RadrootsSecretBackend; + use serde_json::{Map, Value}; use tempfile::tempdir; use super::CoreOperationService; use crate::operation_adapter::{ - AccountCreateRequest, AccountListRequest, OperationAdapter, OperationContext, - OperationRequest, StoreStatusGetRequest, WorkspaceGetRequest, + AccountCreateRequest, AccountImportRequest, AccountListRequest, AccountRemoveRequest, + OperationAdapter, OperationContext, OperationData, OperationRequest, StoreStatusGetRequest, + WorkspaceGetRequest, }; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, @@ -656,6 +668,34 @@ mod tests { assert_eq!(list_envelope.result["accounts"][0]["is_default"], true); } + #[test] + fn core_required_account_approvals_return_approval_error() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let logging = LoggingState { + initialized: false, + current_file: None, + }; + let service = OperationAdapter::new(CoreOperationService::new(&config, &logging)); + let import = OperationRequest::new( + OperationContext::default(), + AccountImportRequest::from_data(data(&[("path", "account.json")])), + ) + .expect("account import request"); + let import_error = service.execute(import).expect_err("approval required"); + assert_eq!(import_error.to_output_error().code, "approval_required"); + assert_eq!(import_error.to_output_error().exit_code, 6); + + let remove = OperationRequest::new( + OperationContext::default(), + AccountRemoveRequest::from_data(data(&[("selector", "acct_test")])), + ) + .expect("account remove request"); + let remove_error = service.execute(remove).expect_err("approval required"); + assert_eq!(remove_error.to_output_error().code, "approval_required"); + assert_eq!(remove_error.to_output_error().exit_code, 6); + } + fn sample_config(root: &Path) -> RuntimeConfig { let data = root.join("data"); let logs = root.join("logs"); @@ -748,4 +788,11 @@ mod tests { capability_bindings: Vec::new(), } } + + fn data(entries: &[(&str, &str)]) -> OperationData { + entries + .iter() + .map(|(key, value)| ((*key).to_owned(), Value::String((*value).to_owned()))) + .collect::<Map<String, Value>>() + } } diff --git a/src/operation_farm.rs b/src/operation_farm.rs @@ -157,10 +157,9 @@ impl OperationService<FarmPublishRequest> for FarmOperationService<'_> { })); } if request.context.approval_token.is_none() { - return Err(OperationAdapterError::InvalidInput { - operation_id: request.operation_id().to_owned(), - message: "missing required `approval_token` input".to_owned(), - }); + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); } let view = map_runtime(crate::runtime::farm::publish(self.config, &args))?; @@ -411,6 +410,8 @@ mod tests { .expect("farm publish request"); let error = service.execute(request).expect_err("approval required"); assert!(format!("{error}").contains("approval_token")); + assert_eq!(error.to_output_error().code, "approval_required"); + assert_eq!(error.to_output_error().exit_code, 6); } fn sample_config(root: &Path) -> RuntimeConfig { diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -206,10 +206,9 @@ where P: OperationRequestPayload + OperationRequestData, { if request.context.approval_token.is_none() { - return Err(OperationAdapterError::InvalidInput { - operation_id: request.operation_id().to_owned(), - message: "missing required `approval_token` input".to_owned(), - }); + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); } Ok(()) } @@ -372,6 +371,8 @@ mod tests { .expect("listing publish request"); let publish_error = service.execute(publish).expect_err("approval required"); assert!(format!("{publish_error}").contains("approval_token")); + assert_eq!(publish_error.to_output_error().code, "approval_required"); + assert_eq!(publish_error.to_output_error().exit_code, 6); let mut context = OperationContext::default(); context.dry_run = true; diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -31,10 +31,9 @@ impl OperationService<OrderSubmitRequest> for OrderOperationService<'_> { request: OperationRequest<OrderSubmitRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { if !request.context.dry_run && request.context.approval_token.is_none() { - return Err(OperationAdapterError::InvalidInput { - operation_id: request.operation_id().to_owned(), - message: "missing required `approval_token` input".to_owned(), - }); + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); } let key = required_order_key(&request)?; @@ -294,6 +293,8 @@ mod tests { let error = service.execute(submit).expect_err("approval required"); assert!(format!("{error}").contains("approval_token")); + assert_eq!(error.to_output_error().code, "approval_required"); + assert_eq!(error.to_output_error().exit_code, 6); } #[test]