cli

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

commit a276c378cfd5c033d300da0350b580064e341d87
parent edc766dcb8f4607d7fd51c467e6099c6251ef88b
Author: triesap <tyson@radroots.org>
Date:   Sat, 25 Apr 2026 10:08:54 +0000

signer: consume myc signer status contract

Diffstat:
Msrc/cli.rs | 6++++++
Msrc/commands/runtime.rs | 1+
Msrc/domain/runtime.rs | 1+
Msrc/render/mod.rs | 8+++++++-
Msrc/runtime/config.rs | 183++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/runtime/myc.rs | 38++++++++++++++++++++++++++++++++------
Msrc/runtime/provider.rs | 1+
Mtests/doctor.rs | 1+
Mtests/farm.rs | 1+
Mtests/find.rs | 1+
Mtests/identity_commands.rs | 1+
Mtests/job_rpc.rs | 1+
Mtests/listing.rs | 4+++-
Mtests/local.rs | 1+
Mtests/market.rs | 1+
Mtests/myc_status.rs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mtests/order.rs | 4+++-
Mtests/relay_net.rs | 1+
Mtests/runtime_management.rs | 1+
Mtests/runtime_show.rs | 4++++
Mtests/sell.rs | 1+
Mtests/signer_status.rs | 1+
Mtests/sync.rs | 1+
Mtests/workflow.rs | 1+
24 files changed, 325 insertions(+), 20 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -71,6 +71,7 @@ Global options --account <ACCOUNT> --signer <SIGNER> --relay <RELAY> + --myc-status-timeout-ms <MS> Examples radroots setup seller @@ -205,6 +206,8 @@ pub struct CliArgs { pub relay: Vec<String>, #[arg(long, global = true)] pub myc_executable: Option<PathBuf>, + #[arg(long = "myc-status-timeout-ms", global = true)] + pub myc_status_timeout_ms: Option<u64>, #[arg(long = "hyf-enabled", global = true, action = ArgAction::SetTrue)] pub hyf_enabled: bool, #[arg(long = "no-hyf-enabled", global = true, action = ArgAction::SetTrue)] @@ -1315,6 +1318,8 @@ mod tests { "wss://relay.two", "--myc-executable", "bin/myc", + "--myc-status-timeout-ms", + "2500", "--hyf-enabled", "--hyf-executable", "bin/hyfd", @@ -1361,6 +1366,7 @@ mod tests { .and_then(|path| path.to_str()), Some("bin/myc") ); + assert_eq!(parsed.myc_status_timeout_ms, Some(2500)); assert!(parsed.hyf_enabled); assert_eq!( parsed diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs @@ -135,6 +135,7 @@ pub fn show( }, myc: MycRuntimeView { executable: config.myc.executable.display().to_string(), + status_timeout_ms: config.myc.status_timeout_ms, }, write_plane: WritePlaneRuntimeView { provider_runtime_id: write_plane.provider_runtime_id, diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -412,6 +412,7 @@ pub struct LocalRuntimeView { #[derive(Debug, Clone, Serialize)] pub struct MycRuntimeView { pub executable: String, + pub status_timeout_ms: u64, } #[derive(Debug, Clone, Serialize)] diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -1259,10 +1259,14 @@ fn render_config_show( ("exports dir", view.local.exports_dir.as_str()), ], )?; + let myc_status_timeout_ms = view.myc.status_timeout_ms.to_string(); render_pairs( stdout, "myc", - &[("executable", view.myc.executable.as_str())], + &[ + ("executable", view.myc.executable.as_str()), + ("status timeout ms", myc_status_timeout_ms.as_str()), + ], )?; let write_plane_target = format_runtime_target( view.write_plane.target_kind.as_deref(), @@ -4516,6 +4520,7 @@ mod tests { }, myc: MycConfig { executable: "myc".into(), + status_timeout_ms: 2_000, }, hyf: HyfConfig { enabled: false, @@ -4671,6 +4676,7 @@ mod tests { }, myc: MycConfig { executable: "myc".into(), + status_timeout_ms: 2_000, }, hyf: HyfConfig { enabled: false, diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -24,6 +24,7 @@ const DEFAULT_LOCAL_DB_FILE: &str = "replica.sqlite"; const DEFAULT_LOCAL_BACKUPS_DIR: &str = "backups"; const DEFAULT_LOCAL_EXPORTS_DIR: &str = "exports"; const DEFAULT_SHARED_ACCOUNTS_STORE_FILE: &str = "store.json"; +const DEFAULT_MYC_STATUS_TIMEOUT_MS: u64 = 2_000; const DEFAULT_HYF_EXECUTABLE: &str = "hyfd"; const DEFAULT_RPC_URL: &str = "http://127.0.0.1:7070"; const CLI_HOST_VAULT_POLICY: &str = "desktop"; @@ -46,6 +47,7 @@ const ENV_IDENTITY_PATH: &str = "RADROOTS_IDENTITY_PATH"; const ENV_SIGNER: &str = "RADROOTS_SIGNER"; const ENV_RELAYS: &str = "RADROOTS_RELAYS"; const ENV_MYC_EXECUTABLE: &str = "RADROOTS_MYC_EXECUTABLE"; +const ENV_MYC_STATUS_TIMEOUT_MS: &str = "RADROOTS_MYC_STATUS_TIMEOUT_MS"; const ENV_HYF_ENABLED: &str = "RADROOTS_HYF_ENABLED"; const ENV_HYF_EXECUTABLE: &str = "RADROOTS_HYF_EXECUTABLE"; const ENV_RPC_URL: &str = "RADROOTS_RPC_URL"; @@ -67,6 +69,7 @@ const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[ ENV_SIGNER, ENV_RELAYS, ENV_MYC_EXECUTABLE, + ENV_MYC_STATUS_TIMEOUT_MS, ENV_HYF_ENABLED, ENV_HYF_EXECUTABLE, ENV_RPC_URL, @@ -229,6 +232,7 @@ pub struct LocalConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct MycConfig { pub executable: PathBuf, + pub status_timeout_ms: u64, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -351,6 +355,7 @@ impl EnvFileValues { #[derive(Debug, Default, Deserialize)] struct CliConfigFile { relay: Option<RelayFileConfig>, + myc: Option<MycFileConfig>, hyf: Option<HyfFileConfig>, rpc: Option<RpcFileConfig>, capability_binding: Option<Vec<CapabilityBindingFileConfig>>, @@ -368,6 +373,12 @@ struct RpcFileConfig { } #[derive(Debug, Default, Deserialize)] +struct MycFileConfig { + executable: Option<PathBuf>, + status_timeout_ms: Option<u64>, +} + +#[derive(Debug, Default, Deserialize)] struct HyfFileConfig { enabled: Option<bool>, executable: Option<PathBuf>, @@ -582,13 +593,13 @@ impl RuntimeConfig { .join(DEFAULT_LOCAL_STATE_DIR) .join(DEFAULT_LOCAL_EXPORTS_DIR), }, - myc: MycConfig { - executable: args - .myc_executable - .clone() - .or_else(|| env_value(env, env_file, &[ENV_MYC_EXECUTABLE]).map(PathBuf::from)) - .unwrap_or_else(|| PathBuf::from("myc")), - }, + myc: resolve_myc_config( + args, + env, + env_file, + app_config.as_ref(), + workspace_config.as_ref(), + )?, hyf: HyfConfig { enabled: resolve_hyf_enabled( args, @@ -941,6 +952,85 @@ fn resolve_relay_config( }) } +fn resolve_myc_config( + args: &CliArgs, + env: &dyn Environment, + env_file: &EnvFileValues, + user_config: Option<&CliConfigFile>, + workspace_config: Option<&CliConfigFile>, +) -> Result<MycConfig, RuntimeError> { + let executable = args + .myc_executable + .clone() + .or_else(|| env_value(env, env_file, &[ENV_MYC_EXECUTABLE]).map(PathBuf::from)) + .or_else(|| { + user_config + .and_then(|config| config.myc.as_ref()) + .and_then(|myc| myc.executable.clone()) + }) + .or_else(|| { + workspace_config + .and_then(|config| config.myc.as_ref()) + .and_then(|myc| myc.executable.clone()) + }) + .unwrap_or_else(|| PathBuf::from("myc")); + + Ok(MycConfig { + executable, + status_timeout_ms: resolve_myc_status_timeout_ms( + args, + env, + env_file, + user_config, + workspace_config, + )?, + }) +} + +fn resolve_myc_status_timeout_ms( + args: &CliArgs, + env: &dyn Environment, + env_file: &EnvFileValues, + user_config: Option<&CliConfigFile>, + workspace_config: Option<&CliConfigFile>, +) -> Result<u64, RuntimeError> { + if let Some(value) = args.myc_status_timeout_ms { + return validate_myc_status_timeout_ms("--myc-status-timeout-ms", value); + } + + if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_MYC_STATUS_TIMEOUT_MS]) { + let parsed = value.trim().parse::<u64>().map_err(|err| { + RuntimeError::Config(format!("{key} must be an integer millisecond value: {err}")) + })?; + return validate_myc_status_timeout_ms(key.as_str(), parsed); + } + + if let Some(value) = user_config + .and_then(|config| config.myc.as_ref()) + .and_then(|myc| myc.status_timeout_ms) + { + return validate_myc_status_timeout_ms("user config [myc].status_timeout_ms", value); + } + + if let Some(value) = workspace_config + .and_then(|config| config.myc.as_ref()) + .and_then(|myc| myc.status_timeout_ms) + { + return validate_myc_status_timeout_ms("workspace config [myc].status_timeout_ms", value); + } + + Ok(DEFAULT_MYC_STATUS_TIMEOUT_MS) +} + +fn validate_myc_status_timeout_ms(source: &str, value: u64) -> Result<u64, RuntimeError> { + if value == 0 { + return Err(RuntimeError::Config(format!( + "{source} must be greater than zero" + ))); + } + Ok(value) +} + fn resolve_hyf_enabled( args: &CliArgs, env: &dyn Environment, @@ -1467,6 +1557,8 @@ mod tests { "wss://relay.two", "--myc-executable", "bin/myc-cli", + "--myc-status-timeout-ms", + "2500", "--hyf-enabled", "--hyf-executable", "bin/hyfd-cli", @@ -1484,6 +1576,10 @@ mod tests { ("RADROOTS_SIGNER".to_owned(), "myc".to_owned()), ("RADROOTS_RELAYS".to_owned(), "wss://relay.env".to_owned()), ("RADROOTS_MYC_EXECUTABLE".to_owned(), "env-myc".to_owned()), + ( + "RADROOTS_MYC_STATUS_TIMEOUT_MS".to_owned(), + "9000".to_owned(), + ), ("RADROOTS_HYF_ENABLED".to_owned(), "false".to_owned()), ("RADROOTS_HYF_EXECUTABLE".to_owned(), "env-hyfd".to_owned()), ])); @@ -1576,6 +1672,7 @@ mod tests { assert_eq!(resolved.relay.source, RelayConfigSource::Flags); assert_eq!(resolved.relay.publish_policy, RelayPublishPolicy::Any); assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc-cli")); + assert_eq!(resolved.myc.status_timeout_ms, 2500); assert_eq!( resolved.hyf, HyfConfig { @@ -1607,6 +1704,10 @@ mod tests { "wss://relay.one,wss://relay.two".to_owned(), ), ("RADROOTS_MYC_EXECUTABLE".to_owned(), "bin/myc".to_owned()), + ( + "RADROOTS_MYC_STATUS_TIMEOUT_MS".to_owned(), + "3500".to_owned(), + ), ("RADROOTS_HYF_ENABLED".to_owned(), "true".to_owned()), ("RADROOTS_HYF_EXECUTABLE".to_owned(), "bin/hyfd".to_owned()), ])); @@ -1656,6 +1757,7 @@ mod tests { ); assert_eq!(resolved.relay.source, RelayConfigSource::Environment); assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc")); + assert_eq!(resolved.myc.status_timeout_ms, 3500); assert_eq!( resolved.hyf, HyfConfig { @@ -1804,6 +1906,21 @@ mod tests { let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) .expect_err("invalid bool"); assert!(error.to_string().contains("RADROOTS_LOG_STDOUT")); + + let env = MapEnvironment::new(BTreeMap::from([( + "RADROOTS_MYC_STATUS_TIMEOUT_MS".to_owned(), + "slow".to_owned(), + )])); + let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect_err("invalid myc timeout"); + assert!(error.to_string().contains("RADROOTS_MYC_STATUS_TIMEOUT_MS")); + + let args = + CliArgs::parse_from(["radroots", "--myc-status-timeout-ms", "0", "config", "show"]); + let env = MapEnvironment::new(BTreeMap::new()); + let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect_err("zero myc timeout"); + assert!(error.to_string().contains("greater than zero")); } #[test] @@ -1821,6 +1938,7 @@ RADROOTS_IDENTITY_PATH=state/identity.json RADROOTS_SIGNER=myc RADROOTS_RELAYS=wss://relay.env-file RADROOTS_MYC_EXECUTABLE=bin/myc +RADROOTS_MYC_STATUS_TIMEOUT_MS=4500 RADROOTS_HYF_ENABLED=true RADROOTS_HYF_EXECUTABLE=bin/hyfd "#, @@ -1843,6 +1961,7 @@ RADROOTS_HYF_EXECUTABLE=bin/hyfd assert_eq!(resolved.relay.urls, vec!["wss://relay.env-file".to_owned()]); assert_eq!(resolved.relay.source, RelayConfigSource::Environment); assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc")); + assert_eq!(resolved.myc.status_timeout_ms, 4500); assert_eq!( resolved.hyf, HyfConfig { @@ -2040,6 +2159,56 @@ RADROOTS_CLI_LOGGING_STDOUT=false } #[test] + fn user_myc_config_overrides_workspace_myc_config() { + let temp = tempdir().expect("tempdir"); + let workspace_root = temp.path().join("workspace"); + let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); + let app_config_dir = repo_local_root.join("config/apps/cli"); + let user_home = temp.path().join("home"); + fs::create_dir_all(&repo_local_root).expect("workspace config dir"); + fs::create_dir_all(&app_config_dir).expect("app config dir"); + fs::write( + repo_local_root.join("config.toml"), + "[myc]\nexecutable = \"workspace-myc\"\nstatus_timeout_ms = 9000\n", + ) + .expect("write workspace config"); + fs::write( + app_config_dir.join("config.toml"), + "[myc]\nexecutable = \"user-myc\"\nstatus_timeout_ms = 3000\n", + ) + .expect("write user config"); + + let env = MapEnvironment { + values: BTreeMap::from([ + ( + "RADROOTS_CLI_PATHS_PROFILE".to_owned(), + "repo_local".to_owned(), + ), + ( + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(), + repo_local_root.display().to_string(), + ), + ]), + current_dir: workspace_root, + path_resolver: RadrootsPathResolver::new( + RadrootsPlatform::Linux, + RadrootsHostEnvironment { + home_dir: Some(user_home), + ..RadrootsHostEnvironment::default() + }, + ), + stdin_tty: false, + stdout_tty: false, + }; + 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.myc.executable, PathBuf::from("user-myc")); + assert_eq!(resolved.myc.status_timeout_ms, 3000); + } + + #[test] fn user_capability_binding_overrides_workspace_binding() { let temp = tempdir().expect("tempdir"); let workspace_root = temp.path().join("workspace"); diff --git a/src/runtime/myc.rs b/src/runtime/myc.rs @@ -14,7 +14,8 @@ use crate::domain::runtime::{ }; use crate::runtime::config::MycConfig; -const MYC_STATUS_TIMEOUT: Duration = Duration::from_secs(1); +const MYC_SIGNER_STATUS_CONTRACT_VERSION: u32 = 1; +const MYC_STATUS_VIEW: &str = "signer"; const MYC_STATUS_POLL_INTERVAL: Duration = Duration::from_millis(10); pub fn resolve_status(config: &MycConfig) -> MycStatusView { @@ -55,7 +56,7 @@ pub fn resolve_status(config: &MycConfig) -> MycStatusView { "unavailable", format!( "myc status command timed out after {}ms", - MYC_STATUS_TIMEOUT.as_millis() + config.status_timeout_ms ), ); } @@ -95,24 +96,46 @@ pub fn resolve_status(config: &MycConfig) -> MycStatusView { } }; - let payload = match serde_json::from_str::<MycStatusPayload>(stdout.as_str()) { + let payload_value = match serde_json::from_str::<serde_json::Value>(stdout.as_str()) { Ok(payload) => payload, Err(error) => { return unavailable_status( executable, "unavailable", - format!("myc status output was not valid JSON: {error}"), + format!("myc signer status output was not valid JSON: {error}"), + ); + } + }; + let payload = match serde_json::from_value::<MycStatusPayload>(payload_value) { + Ok(payload) => payload, + Err(error) => { + return unavailable_status( + executable, + "unavailable", + format!( + "myc signer status output did not match contract version {MYC_SIGNER_STATUS_CONTRACT_VERSION}: {error}" + ), ); } }; let MycStatusPayload { + status_contract_version, status, ready, reasons, signer_backend, custody, } = payload; + if status_contract_version != MYC_SIGNER_STATUS_CONTRACT_VERSION { + return unavailable_status( + executable, + "unavailable", + format!( + "myc signer status contract version {status_contract_version} is incompatible with cli expected {MYC_SIGNER_STATUS_CONTRACT_VERSION}" + ), + ); + } let MycSignerBackendPayload { local_signer, remote_session_count, @@ -153,7 +176,7 @@ pub fn resolve_status(config: &MycConfig) -> MycStatusView { fn run_status_command(config: &MycConfig) -> Result<Output, MycCommandError> { let mut child = Command::new(&config.executable) - .args(["status", "--view", "full"]) + .args(["status", "--view", MYC_STATUS_VIEW]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() @@ -163,11 +186,12 @@ fn run_status_command(config: &MycConfig) -> Result<Output, MycCommandError> { })?; let started_at = Instant::now(); + let status_timeout = Duration::from_millis(config.status_timeout_ms); loop { match child.try_wait() { Ok(Some(status)) => return collect_output(child, status), Ok(None) => { - if started_at.elapsed() >= MYC_STATUS_TIMEOUT { + if started_at.elapsed() >= status_timeout { let _ = child.kill(); let _ = child.wait(); return Err(MycCommandError::Timeout); @@ -277,6 +301,7 @@ enum MycCommandError { #[derive(Debug, Deserialize)] struct MycStatusPayload { + status_contract_version: u32, status: String, ready: bool, #[serde(default)] @@ -382,6 +407,7 @@ mod tests { fn empty_executable_path_reports_unconfigured_status() { let view = resolve_status(&MycConfig { executable: PathBuf::new(), + status_timeout_ms: 2_000, }); assert_eq!(view.state, "unconfigured"); diff --git a/src/runtime/provider.rs b/src/runtime/provider.rs @@ -816,6 +816,7 @@ mod tests { }, myc: MycConfig { executable: PathBuf::from("myc"), + status_timeout_ms: 2_000, }, hyf: HyfConfig { enabled: hyf_enabled, diff --git a/tests/doctor.rs b/tests/doctor.rs @@ -29,6 +29,7 @@ fn doctor_command_in(workdir: &Path) -> Command { "RADROOTS_SIGNER", "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_MYC_STATUS_TIMEOUT_MS", "RADROOTS_RPC_URL", "RADROOTS_RPC_BEARER_TOKEN", ] { diff --git a/tests/farm.rs b/tests/farm.rs @@ -30,6 +30,7 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_SIGNER", "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_MYC_STATUS_TIMEOUT_MS", "RADROOTS_RPC_URL", "RADROOTS_RPC_BEARER_TOKEN", ] { diff --git a/tests/find.rs b/tests/find.rs @@ -44,6 +44,7 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_SIGNER", "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_MYC_STATUS_TIMEOUT_MS", "RADROOTS_RPC_URL", "RADROOTS_RPC_BEARER_TOKEN", ] { diff --git a/tests/identity_commands.rs b/tests/identity_commands.rs @@ -48,6 +48,7 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_SIGNER", "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_MYC_STATUS_TIMEOUT_MS", "RADROOTS_RPC_URL", "RADROOTS_RPC_BEARER_TOKEN", ] { diff --git a/tests/job_rpc.rs b/tests/job_rpc.rs @@ -35,6 +35,7 @@ fn job_rpc_command_in(workdir: &Path) -> Command { "RADROOTS_SIGNER", "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_MYC_STATUS_TIMEOUT_MS", "RADROOTS_RPC_URL", "RADROOTS_RPC_BEARER_TOKEN", ] { diff --git a/tests/listing.rs b/tests/listing.rs @@ -51,6 +51,7 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_SIGNER", "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_MYC_STATUS_TIMEOUT_MS", "RADROOTS_RPC_URL", "RADROOTS_RPC_BEARER_TOKEN", ] { @@ -97,7 +98,7 @@ fn write_fake_myc(dir: &Path, script: &str) -> std::path::PathBuf { fn successful_status_script(payload_json: String) -> String { format!( - "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"full\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\ncat <<'JSON'\n{payload_json}\nJSON\n" + "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"signer\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\ncat <<'JSON'\n{payload_json}\nJSON\n" ) } @@ -114,6 +115,7 @@ fn sample_myc_status_payload( .to_owned(); assert_ne!(signer_account_id, account_id); json!({ + "status_contract_version": 1, "status": "healthy", "ready": true, "reasons": [], diff --git a/tests/local.rs b/tests/local.rs @@ -39,6 +39,7 @@ fn local_command_in(workdir: &Path) -> Command { "RADROOTS_SIGNER", "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_MYC_STATUS_TIMEOUT_MS", "RADROOTS_RPC_URL", "RADROOTS_RPC_BEARER_TOKEN", ] { diff --git a/tests/market.rs b/tests/market.rs @@ -45,6 +45,7 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_SIGNER", "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_MYC_STATUS_TIMEOUT_MS", "RADROOTS_RPC_URL", "RADROOTS_RPC_BEARER_TOKEN", ] { diff --git a/tests/myc_status.rs b/tests/myc_status.rs @@ -33,6 +33,7 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_SIGNER", "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_MYC_STATUS_TIMEOUT_MS", "RADROOTS_RPC_URL", "RADROOTS_RPC_BEARER_TOKEN", ] { @@ -49,7 +50,7 @@ fn write_user_config(workdir: &Path, contents: &str) { } #[test] -fn myc_status_reports_ready_for_valid_full_status_payload() { +fn myc_status_reports_ready_for_valid_signer_status_payload() { let _guard = myc_test_guard(); let dir = tempdir().expect("tempdir"); let executable = write_fake_myc( @@ -115,6 +116,75 @@ fn myc_status_reports_unavailable_for_invalid_status_payload() { } #[test] +fn myc_status_rejects_missing_signer_status_contract_version() { + let _guard = myc_test_guard(); + let dir = tempdir().expect("tempdir"); + let mut payload = sample_status_payload(true); + payload + .as_object_mut() + .expect("payload object") + .remove("status_contract_version"); + let executable = write_fake_myc( + dir.path(), + successful_status_script(payload.to_string()).as_str(), + ); + + let output = cli_command_in(dir.path()) + .args([ + "--json", + "--myc-executable", + executable.to_str().expect("executable path"), + "myc", + "status", + ]) + .output() + .expect("run myc status"); + + assert_eq!(output.status.code(), Some(4)); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); + assert_eq!(json["state"], "unavailable"); + assert!( + json["reason"] + .as_str() + .is_some_and(|value| value.contains("contract version 1")) + ); +} + +#[test] +fn myc_status_rejects_incompatible_signer_status_contract_version() { + let _guard = myc_test_guard(); + let dir = tempdir().expect("tempdir"); + let mut payload = sample_status_payload(true); + payload["status_contract_version"] = json!(99); + let executable = write_fake_myc( + dir.path(), + successful_status_script(payload.to_string()).as_str(), + ); + + let output = cli_command_in(dir.path()) + .args([ + "--json", + "--myc-executable", + executable.to_str().expect("executable path"), + "myc", + "status", + ]) + .output() + .expect("run myc status"); + + assert_eq!(output.status.code(), Some(4)); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); + assert_eq!(json["state"], "unavailable"); + assert!( + json["reason"] + .as_str() + .is_some_and(|value| value.contains("contract version 99")) + ); +} + +#[test] fn myc_status_reports_degraded_service_as_external_unavailable() { let _guard = myc_test_guard(); let dir = tempdir().expect("tempdir"); @@ -684,12 +754,14 @@ fn myc_status_reports_unavailable_for_timeout() { let dir = tempdir().expect("tempdir"); let executable = write_fake_myc( dir.path(), - "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"full\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\nexec sleep 5\n", + "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"signer\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\nexec sleep 5\n", ); let output = cli_command_in(dir.path()) .args([ "--json", + "--myc-status-timeout-ms", + "25", "--myc-executable", executable.to_str().expect("executable path"), "myc", @@ -705,7 +777,7 @@ fn myc_status_reports_unavailable_for_timeout() { assert!( json["reason"] .as_str() - .is_some_and(|value| value.contains("timed out")) + .is_some_and(|value| value.contains("timed out after 25ms")) ); } @@ -727,7 +799,7 @@ fn myc_test_guard() -> MutexGuard<'static, ()> { fn successful_status_script(payload_json: String) -> String { format!( - "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"full\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\ncat <<'JSON'\n{payload_json}\nJSON\n" + "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"signer\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\ncat <<'JSON'\n{payload_json}\nJSON\n" ) } @@ -751,6 +823,7 @@ fn sample_status_payload_with_permissions(ready: bool, permissions: &[&str]) -> }; json!({ + "status_contract_version": 1, "status": service_status, "ready": ready, "reasons": reasons, @@ -801,6 +874,7 @@ fn sample_multi_session_status_payload() -> Value { let first_user_public_key_hex = first_user.public_key_hex.clone(); json!({ + "status_contract_version": 1, "status": "healthy", "ready": true, "reasons": [], diff --git a/tests/order.rs b/tests/order.rs @@ -55,6 +55,7 @@ fn order_command_in(workdir: &Path) -> Command { "RADROOTS_SIGNER", "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_MYC_STATUS_TIMEOUT_MS", "RADROOTS_RPC_URL", "RADROOTS_RPC_BEARER_TOKEN", ] { @@ -185,7 +186,7 @@ fn write_fake_myc(dir: &Path, script: &str) -> std::path::PathBuf { fn successful_status_script(payload_json: String) -> String { format!( - "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"full\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\ncat <<'JSON'\n{payload_json}\nJSON\n" + "#!/bin/sh\nif [ \"$1\" != \"status\" ] || [ \"$2\" != \"--view\" ] || [ \"$3\" != \"signer\" ]; then\n echo \"unexpected args: $*\" >&2\n exit 64\nfi\ncat <<'JSON'\n{payload_json}\nJSON\n" ) } @@ -202,6 +203,7 @@ fn sample_myc_status_payload( .to_owned(); assert_ne!(signer_account_id, account_id); json!({ + "status_contract_version": 1, "status": "healthy", "ready": true, "reasons": [], diff --git a/tests/relay_net.rs b/tests/relay_net.rs @@ -29,6 +29,7 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_SIGNER", "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_MYC_STATUS_TIMEOUT_MS", "RADROOTS_RPC_URL", "RADROOTS_RPC_BEARER_TOKEN", ] { diff --git a/tests/runtime_management.rs b/tests/runtime_management.rs @@ -80,6 +80,7 @@ fn runtime_command_in(workdir: &Path) -> Command { "RADROOTS_SIGNER", "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_MYC_STATUS_TIMEOUT_MS", "RADROOTS_RPC_URL", "RADROOTS_RPC_BEARER_TOKEN", ] { diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -83,6 +83,7 @@ fn runtime_show_command_in(workdir: &Path) -> Command { "RADROOTS_SIGNER", "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_MYC_STATUS_TIMEOUT_MS", "RADROOTS_RPC_URL", "RADROOTS_RPC_BEARER_TOKEN", ] { @@ -286,6 +287,7 @@ fn config_show_json_reports_default_bootstrap_state() { .to_string() ); assert_eq!(json["myc"]["executable"], "myc"); + assert_eq!(json["myc"]["status_timeout_ms"], 2000); assert_eq!(json["write_plane"]["provider_runtime_id"], "radrootsd"); assert_eq!( json["write_plane"]["binding_model"], @@ -541,6 +543,7 @@ fn config_show_json_reflects_environment_configuration() { .env("RADROOTS_SIGNER", "myc") .env("RADROOTS_RELAYS", "wss://relay.one,wss://relay.two") .env("RADROOTS_MYC_EXECUTABLE", "bin/myc") + .env("RADROOTS_MYC_STATUS_TIMEOUT_MS", "3500") .env("RADROOTS_RPC_URL", "https://rpc.radroots.test/jsonrpc") .env("RADROOTS_RPC_BEARER_TOKEN", "secret") .args(["config", "show"]) @@ -564,6 +567,7 @@ fn config_show_json_reflects_environment_configuration() { assert_eq!(json["relay"]["urls"][0], "wss://relay.one"); assert_eq!(json["relay"]["source"], "environment ยท local first"); assert_eq!(json["myc"]["executable"], "bin/myc"); + assert_eq!(json["myc"]["status_timeout_ms"], 3500); assert_eq!(json["rpc"]["url"], "https://rpc.radroots.test/jsonrpc"); assert_eq!(json["rpc"]["bridge_auth_configured"], true); } diff --git a/tests/sell.rs b/tests/sell.rs @@ -34,6 +34,7 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_SIGNER", "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_MYC_STATUS_TIMEOUT_MS", "RADROOTS_RPC_URL", "RADROOTS_RPC_BEARER_TOKEN", ] { diff --git a/tests/signer_status.rs b/tests/signer_status.rs @@ -39,6 +39,7 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_SIGNER", "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_MYC_STATUS_TIMEOUT_MS", "RADROOTS_RPC_URL", "RADROOTS_RPC_BEARER_TOKEN", ] { diff --git a/tests/sync.rs b/tests/sync.rs @@ -29,6 +29,7 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_SIGNER", "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_MYC_STATUS_TIMEOUT_MS", "RADROOTS_RPC_URL", "RADROOTS_RPC_BEARER_TOKEN", ] { diff --git a/tests/workflow.rs b/tests/workflow.rs @@ -39,6 +39,7 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_SIGNER", "RADROOTS_RELAYS", "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_MYC_STATUS_TIMEOUT_MS", "RADROOTS_RPC_URL", "RADROOTS_RPC_BEARER_TOKEN", ] {