cli

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

commit 9d8004ea1dad724340952ecfe0e3e02d073fd6e5
parent 83264c4da582d810e0142921f1c841bd92652b15
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 07:21:43 +0000

cli: defer myc target runtime

Diffstat:
Msrc/main.rs | 20+++++++++++++++++++-
Msrc/operation_adapter.rs | 24++++++++++++++++++++++++
Msrc/runtime/farm.rs | 10++--------
Msrc/runtime/listing.rs | 10++--------
Msrc/runtime/order.rs | 10++--------
Mtests/signer_runtime_modes.rs | 311+++++++++----------------------------------------------------------------------
6 files changed, 81 insertions(+), 304 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -34,7 +34,7 @@ use crate::operation_market::MarketOperationService; use crate::operation_order::OrderOperationService; use crate::operation_runtime::RuntimeOperationService; use crate::output_contract::OutputEnvelope; -use crate::runtime::config::RuntimeConfig; +use crate::runtime::config::{RuntimeConfig, SignerBackend}; use crate::runtime::logging::initialize_logging; use crate::target_cli::{TargetCliArgs, TargetOutputFormat}; @@ -337,10 +337,28 @@ fn validate_request_contract( message: format!("`{}` does not support --dry-run", spec.cli_path), }); } + validate_signer_mode_contract(request, config)?; validate_network_contract(request, config)?; Ok(()) } +fn validate_signer_mode_contract( + request: &TargetOperationRequest, + config: &RuntimeConfig, +) -> Result<(), OperationAdapterError> { + if matches!(config.signer.backend, SignerBackend::Myc) { + let spec = request.spec(); + return Err(OperationAdapterError::SignerModeDeferred { + operation_id: spec.operation_id.to_owned(), + message: format!( + "`{}` cannot run with signer mode `myc`; use signer mode `local`", + spec.cli_path + ), + }); + } + Ok(()) +} + fn validate_network_contract( request: &TargetOperationRequest, config: &RuntimeConfig, diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -334,6 +334,11 @@ pub enum OperationAdapterError { operation_id: String, message: String, }, + #[error("signer mode deferred for `{operation_id}`: {message}")] + SignerModeDeferred { + operation_id: String, + message: String, + }, #[error("provider unconfigured for `{operation_id}`: {message}")] ProviderUnconfigured { operation_id: String, @@ -447,6 +452,16 @@ impl OperationAdapterError { message, CliExitCode::SignerUnavailable, ), + Self::SignerModeDeferred { + operation_id, + message, + } => runtime_output_error( + "signer_mode_deferred", + operation_id, + "signer", + message, + CliExitCode::SignerUnavailable, + ), Self::ProviderUnconfigured { operation_id, message, @@ -1326,6 +1341,15 @@ mod tests { 3, ), ( + OperationAdapterError::SignerModeDeferred { + operation_id: "signer.status.get".to_owned(), + message: "signer mode `myc` is deferred".to_owned(), + }, + "signer_mode_deferred", + "signer", + 7, + ), + ( OperationAdapterError::unconfigured( "basket.quote.create", "quote engine not ready".to_owned(), diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -756,18 +756,12 @@ fn binding_error_publish_view( ActorWriteBindingError::Unconfigured(reason) => ( "unconfigured".to_owned(), reason, - vec![ - "set RADROOTS_SIGNER=myc and run radroots signer status get".to_owned(), - "configure signer.remote_nip46 capability binding".to_owned(), - ], + vec!["run radroots signer status get".to_owned()], ), ActorWriteBindingError::Unavailable(reason) => ( "unavailable".to_owned(), reason, - vec![ - "RADROOTS_SIGNER=myc radroots signer status get".to_owned(), - "verify RADROOTS_MYC_EXECUTABLE and signer.remote_nip46 binding".to_owned(), - ], + vec!["run radroots signer status get".to_owned()], ), }; base_publish_view( diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -1843,18 +1843,12 @@ fn binding_error_view( ActorWriteBindingError::Unconfigured(reason) => ( "unconfigured".to_owned(), reason, - vec![ - "set RADROOTS_SIGNER=myc and run radroots signer status get".to_owned(), - "configure signer.remote_nip46 capability binding".to_owned(), - ], + vec!["run radroots signer status get".to_owned()], ), ActorWriteBindingError::Unavailable(reason) => ( "unavailable".to_owned(), reason, - vec![ - "RADROOTS_SIGNER=myc radroots signer status get".to_owned(), - "verify RADROOTS_MYC_EXECUTABLE and signer.remote_nip46 binding".to_owned(), - ], + vec!["run radroots signer status get".to_owned()], ), }; diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -1389,18 +1389,12 @@ fn order_binding_error_view( ActorWriteBindingError::Unconfigured(reason) => ( "unconfigured".to_owned(), reason, - vec![ - "set RADROOTS_SIGNER=myc and run radroots signer status get".to_owned(), - "configure signer.remote_nip46 capability binding".to_owned(), - ], + vec!["run radroots signer status get".to_owned()], ), ActorWriteBindingError::Unavailable(reason) => ( "unavailable".to_owned(), reason, - vec![ - "RADROOTS_SIGNER=myc radroots signer status get".to_owned(), - "verify RADROOTS_MYC_EXECUTABLE and signer.remote_nip46 binding".to_owned(), - ], + vec!["run radroots signer status get".to_owned()], ), }; diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -1,11 +1,9 @@ mod support; -use std::fs; use std::path::Path; use radroots_events::kinds::KIND_LISTING; -use radroots_identity::RadrootsIdentityPublic; -use serde_json::{Value, json}; +use serde_json::json; use support::{ RadrootsCliSandbox, assert_contains, assert_hex_len, create_listing_draft, identity_public, make_listing_publishable, shell_single_quoted, toml_string, write_public_identity_profile, @@ -224,219 +222,43 @@ fn watch_only_import_reports_unconfigured_local_signer() { } #[test] -fn myc_signer_status_reports_unavailable_for_missing_executable() { +fn myc_signer_status_returns_deferred_signer_error() { let sandbox = RadrootsCliSandbox::new(); let missing_myc = sandbox.root().join("bin/missing-myc"); configure_myc_mode(&sandbox, &missing_myc); - let value = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); - - assert_eq!(value["operation_id"], "signer.status.get"); - assert_eq!(value["result"]["mode"], "myc"); - assert_eq!(value["result"]["state"], "unavailable"); - assert_eq!(value["result"]["myc"]["state"], "unavailable"); - assert_contains(&value["result"]["myc"]["reason"], "not found"); -} - -#[cfg(unix)] -#[test] -fn myc_signer_status_reports_unavailable_for_command_failure() { - let sandbox = RadrootsCliSandbox::new(); - let myc = sandbox.write_fake_myc( - "myc-failure", - myc_status_body("printf 'fake myc failed\\n' >&2\nexit 42").as_str(), - ); - configure_myc_mode(&sandbox, &myc); - - let value = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); - - assert_eq!(value["operation_id"], "signer.status.get"); - assert_eq!(value["result"]["mode"], "myc"); - assert_eq!(value["result"]["state"], "unavailable"); - assert_eq!(value["result"]["myc"]["state"], "unavailable"); - assert_contains(&value["result"]["myc"]["reason"], "status code 42"); - assert_contains(&value["result"]["myc"]["reason"], "fake myc failed"); -} - -#[cfg(unix)] -#[test] -fn myc_signer_status_reports_unavailable_for_invalid_json() { - let sandbox = RadrootsCliSandbox::new(); - let myc = sandbox.write_fake_myc( - "myc-invalid-json", - myc_status_body("printf 'not json\\n'").as_str(), - ); - configure_myc_mode(&sandbox, &myc); - - let value = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); + let (output, value) = sandbox.json_output(&["--format", "json", "signer", "status", "get"]); + assert!(!output.status.success()); assert_eq!(value["operation_id"], "signer.status.get"); - assert_eq!(value["result"]["mode"], "myc"); - assert_eq!(value["result"]["state"], "unavailable"); - assert_eq!(value["result"]["myc"]["state"], "unavailable"); - assert_contains(&value["result"]["myc"]["reason"], "not valid JSON"); + assert_eq!(value["result"], serde_json::Value::Null); + assert_eq!(value["errors"][0]["code"], "signer_mode_deferred"); + assert_eq!(value["errors"][0]["exit_code"], 7); + assert_eq!(value["errors"][0]["detail"]["class"], "signer"); + assert_contains(&value["errors"][0]["message"], "signer mode `myc`"); } #[cfg(unix)] #[test] -fn myc_signer_status_invokes_exact_status_view_argv() { +fn myc_signer_status_does_not_invoke_configured_executable() { let sandbox = RadrootsCliSandbox::new(); - let argv_log = sandbox.root().join("myc-argv.txt"); - let payload = ready_myc_payload(Vec::new()); - let raw = serde_json::to_string(&payload).expect("myc status payload"); - let body = format!( - "printf '%s\\n' \"$*\" > '{}'\nprintf '%s\\n' '{}'", - shell_single_quoted(argv_log.to_string_lossy().as_ref()), - shell_single_quoted(raw.as_str()) - ); + let invoked = sandbox.root().join("myc-invoked.txt"); let myc = sandbox.write_fake_myc( - "myc-exact-status-argv", - myc_status_body(body.as_str()).as_str(), + "myc-deferred", + format!( + "printf invoked > '{}'", + shell_single_quoted(invoked.to_string_lossy().as_ref()) + ) + .as_str(), ); configure_myc_mode(&sandbox, &myc); - let value = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); - - assert_eq!(value["operation_id"], "signer.status.get"); - assert_eq!(value["result"]["mode"], "myc"); - assert_eq!( - fs::read_to_string(argv_log).expect("myc argv log"), - "status --view signer\n" - ); -} - -#[cfg(unix)] -#[test] -fn myc_signer_status_reports_unconfigured_when_ready_without_binding() { - let sandbox = RadrootsCliSandbox::new(); - let myc = write_fake_myc_status( - &sandbox, - "myc-ready-no-binding", - ready_myc_payload(Vec::new()), - ); - configure_myc_mode(&sandbox, &myc); - - let value = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); + let (output, value) = sandbox.json_output(&["--format", "json", "signer", "status", "get"]); + assert!(!output.status.success()); assert_eq!(value["operation_id"], "signer.status.get"); - assert_eq!(value["result"]["mode"], "myc"); - assert_eq!(value["result"]["state"], "unconfigured"); - assert_eq!(value["result"]["myc"]["state"], "ready"); - assert_eq!(value["result"]["myc"]["ready"], true); - assert_eq!(value["result"]["myc"]["remote_session_count"], 0); - assert_eq!(value["result"]["binding"]["state"], "unconfigured"); - assert_contains(&value["result"]["binding"]["reason"], "signer.remote_nip46"); -} - -#[cfg(unix)] -#[test] -fn myc_binding_reports_unsupported_target_kind() { - let sandbox = RadrootsCliSandbox::new(); - let myc = write_fake_myc_status( - &sandbox, - "myc-ready-unsupported", - ready_myc_payload(Vec::new()), - ); - configure_myc_mode_with_binding(&sandbox, &myc, "explicit_endpoint", "default", None, None); - - let value = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); - - assert_eq!(value["result"]["mode"], "myc"); - assert_eq!(value["result"]["state"], "unconfigured"); - assert_eq!(value["result"]["binding"]["state"], "unsupported"); - assert_contains(&value["result"]["binding"]["reason"], "target_kind"); -} - -#[cfg(unix)] -#[test] -fn myc_binding_reports_no_authorized_sessions() { - let sandbox = RadrootsCliSandbox::new(); - let signer = identity_public(2); - let user = identity_public(3); - let payload = ready_myc_payload(vec![remote_session("session-ping", &signer, &user, "ping")]); - let myc = write_fake_myc_status(&sandbox, "myc-no-authorized", payload); - configure_myc_mode_with_binding(&sandbox, &myc, "managed_instance", "default", None, None); - - let value = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); - - assert_eq!(value["result"]["mode"], "myc"); - assert_eq!(value["result"]["state"], "unavailable"); - assert_eq!(value["result"]["binding"]["state"], "unavailable"); - assert_eq!(value["result"]["binding"]["matched_session_count"], 0); - assert_contains( - &value["result"]["binding"]["reason"], - "no authorized remote signer session", - ); -} - -#[cfg(unix)] -#[test] -fn myc_binding_reports_ambiguous_authorized_sessions() { - let sandbox = RadrootsCliSandbox::new(); - let signer = identity_public(4); - let user_one = identity_public(5); - let user_two = identity_public(6); - let payload = ready_myc_payload(vec![ - remote_session("session-one", &signer, &user_one, "sign_event"), - remote_session("session-two", &signer, &user_two, "sign_event"), - ]); - let myc = write_fake_myc_status(&sandbox, "myc-ambiguous", payload); - configure_myc_mode_with_binding(&sandbox, &myc, "managed_instance", "default", None, None); - - let value = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); - - assert_eq!(value["result"]["mode"], "myc"); - assert_eq!(value["result"]["state"], "unconfigured"); - assert_eq!(value["result"]["binding"]["state"], "ambiguous"); - assert_eq!(value["result"]["binding"]["matched_session_count"], 2); - assert_contains( - &value["result"]["binding"]["reason"], - "multiple authorized remote signer sessions", - ); -} - -#[cfg(unix)] -#[test] -fn myc_binding_reports_ready_for_one_authorized_session() { - let sandbox = RadrootsCliSandbox::new(); - let signer = identity_public(7); - let user = identity_public(8); - let payload = ready_myc_payload(vec![remote_session( - "session-ready", - &signer, - &user, - "sign_event", - )]); - let myc = write_fake_myc_status(&sandbox, "myc-ready-bound", payload); - configure_myc_mode_with_binding( - &sandbox, - &myc, - "managed_instance", - "default", - Some(user.id.as_str()), - None, - ); - - let value = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); - - assert_eq!(value["result"]["mode"], "myc"); - assert_eq!(value["result"]["state"], "ready"); - assert_eq!(value["result"]["signer_account_id"], user.id.as_str()); - assert_eq!(value["result"]["binding"]["state"], "ready"); - assert_eq!( - value["result"]["binding"]["resolved_signer_session_id"], - "session-ready" - ); - assert_eq!(value["result"]["binding"]["matched_session_count"], 1); - assert_eq!( - value["result"]["write_kinds"] - .as_array() - .expect("write kinds") - .iter() - .filter(|kind| kind["ready"] == true) - .count(), - 4 - ); + assert_eq!(value["errors"][0]["code"], "signer_mode_deferred"); + assert!(!invoked.exists(), "target CLI must not execute MYC"); } #[test] @@ -564,10 +386,14 @@ fn myc_listing_publish_does_not_fallback_to_local_account() { sandbox.json_success(&["--format", "json", "account", "create"]); let listing_file = create_listing_draft(&sandbox, "myc-no-binding"); make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); - let myc = write_fake_myc_status( - &sandbox, - "myc-ready-no-write-binding", - ready_myc_payload(Vec::new()), + let invoked = sandbox.root().join("myc-listing-invoked.txt"); + let myc = sandbox.write_fake_myc( + "myc-listing-deferred", + format!( + "printf invoked > '{}'", + shell_single_quoted(invoked.to_string_lossy().as_ref()) + ) + .as_str(), ); configure_myc_mode(&sandbox, &myc); @@ -584,10 +410,11 @@ fn myc_listing_publish_does_not_fallback_to_local_account() { assert!(!output.status.success()); assert_eq!(value["operation_id"], "listing.publish"); assert_eq!(value["result"], serde_json::Value::Null); - assert_eq!(value["errors"][0]["code"], "signer_unconfigured"); + assert_eq!(value["errors"][0]["code"], "signer_mode_deferred"); assert_eq!(value["errors"][0]["exit_code"], 7); assert_eq!(value["errors"][0]["detail"]["class"], "signer"); - assert_contains(&value["errors"][0]["message"], "signer.remote_nip46"); + assert_contains(&value["errors"][0]["message"], "signer mode `myc`"); + assert!(!invoked.exists(), "target CLI must not execute MYC"); } fn configure_myc_mode(sandbox: &RadrootsCliSandbox, executable: &Path) { @@ -596,77 +423,3 @@ fn configure_myc_mode(sandbox: &RadrootsCliSandbox, executable: &Path) { toml_string(executable.display().to_string().as_str()) )); } - -fn configure_myc_mode_with_binding( - sandbox: &RadrootsCliSandbox, - executable: &Path, - target_kind: &str, - target: &str, - managed_account_ref: Option<&str>, - signer_session_ref: Option<&str>, -) { - let mut raw = format!( - "[signer]\nmode = \"myc\"\n\n[myc]\nexecutable = \"{}\"\n\n[[capability_binding]]\ncapability = \"signer.remote_nip46\"\nprovider = \"myc\"\ntarget_kind = \"{}\"\ntarget = \"{}\"\n", - toml_string(executable.display().to_string().as_str()), - toml_string(target_kind), - toml_string(target) - ); - if let Some(value) = managed_account_ref { - raw.push_str(&format!( - "managed_account_ref = \"{}\"\n", - toml_string(value) - )); - } - if let Some(value) = signer_session_ref { - raw.push_str(&format!( - "signer_session_ref = \"{}\"\n", - toml_string(value) - )); - } - sandbox.write_app_config(raw.as_str()); -} - -#[cfg(unix)] -fn write_fake_myc_status( - sandbox: &RadrootsCliSandbox, - name: &str, - payload: Value, -) -> std::path::PathBuf { - let raw = serde_json::to_string(&payload).expect("myc status payload"); - let body = format!("printf '%s\\n' '{}'", shell_single_quoted(raw.as_str())); - sandbox.write_fake_myc(name, myc_status_body(body.as_str()).as_str()) -} - -#[cfg(unix)] -fn myc_status_body(body: &str) -> String { - format!( - "if [ \"$#\" -ne 3 ] || [ \"$1\" != 'status' ] || [ \"$2\" != '--view' ] || [ \"$3\" != 'signer' ]; then\nprintf '%s\\n' \"unexpected myc argv: $*\" >&2\nexit 64\nfi\n{body}" - ) -} - -fn ready_myc_payload(remote_sessions: Vec<Value>) -> Value { - json!({ - "status_contract_version": 1, - "status": "ready", - "ready": true, - "signer_backend": { - "remote_session_count": remote_sessions.len(), - "remote_sessions": remote_sessions - } - }) -} - -fn remote_session( - connection_id: &str, - signer_identity: &RadrootsIdentityPublic, - user_identity: &RadrootsIdentityPublic, - permissions: &str, -) -> Value { - json!({ - "connection_id": connection_id, - "signer_identity": signer_identity, - "user_identity": user_identity, - "relays": ["wss://relay.example.test"], - "permissions": permissions - }) -}