commit 9d8004ea1dad724340952ecfe0e3e02d073fd6e5
parent 83264c4da582d810e0142921f1c841bd92652b15
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Apr 2026 07:21:43 +0000
cli: defer myc target runtime
Diffstat:
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
- })
-}