commit 6f70dc2dfa821f3a0e62d9485bf21f8817ec7103
parent 7e9023919e2ad6b1748be4ea17d2213321bfb864
Author: triesap <tyson@radroots.org>
Date: Sat, 27 Jun 2026 08:35:18 +0000
cli: warn on farm-bound account removal
- detect approved account removal that orphans the farm seller binding
- surface farm_bound_seller_orphaned warnings with recovery actions
- render removed account fields in terminal account output
- cover farm-bound and non-farm-bound removal in signer tests
Diffstat:
3 files changed, 255 insertions(+), 2 deletions(-)
diff --git a/src/ops/exec/core.rs b/src/ops/exec/core.rs
@@ -18,6 +18,7 @@ use crate::ops::{
StoreInitRequest, StoreInitResult, StoreStatusGetRequest, StoreStatusGetResult,
WorkspaceGetRequest, WorkspaceGetResult, WorkspaceInitRequest, WorkspaceInitResult,
};
+use crate::out::envelope::OutputWarning;
use crate::runtime::RuntimeError;
use crate::runtime::account::{
AccountResolution, AccountRuntimeFailure, account_resolution_view, account_summary_view,
@@ -483,15 +484,29 @@ impl OperationService<AccountRemoveRequest> for CoreOperationService<'_> {
));
}
+ let resolved_farm_config =
+ map_runtime(crate::runtime::farm_config::load(self.config, None))?;
let result = remove_account(self.config, selector.as_str()).map_err(|error| {
OperationAdapterError::unconfigured(request.operation_id(), error.to_string())
})?;
- json_operation_result::<AccountRemoveResult>(json!({
+ let removed_account_id = result.removed_account.record.account_id.to_string();
+ let farm_orphan_warning =
+ account_remove_farm_orphan_warning(resolved_farm_config.as_ref(), &removed_account_id);
+ let mut result_value = json!({
"state": "removed",
"removed_account": account_summary_view(&result.removed_account),
"default_cleared": result.default_cleared,
"remaining_account_count": result.remaining_account_count,
- }))
+ });
+ if let Some(warning) = farm_orphan_warning.as_ref() {
+ result_value["warnings"] = json!([warning.result_value()]);
+ result_value["actions"] = json!(warning.actions.clone());
+ }
+ let mut operation_result = json_operation_result::<AccountRemoveResult>(result_value)?;
+ if let Some(warning) = farm_orphan_warning {
+ operation_result.warnings.push(warning.output_warning());
+ }
+ Ok(operation_result)
}
}
@@ -1018,6 +1033,58 @@ fn config_actions(
publish_recovery_actions(config, account, publish)
}
+#[derive(Debug, Clone)]
+struct AccountRemoveFarmOrphanWarning {
+ message: String,
+ subject_account_id: String,
+ farm_config_scope: String,
+ farm_config_path: String,
+ actions: Vec<String>,
+}
+
+impl AccountRemoveFarmOrphanWarning {
+ const CODE: &'static str = "farm_bound_seller_orphaned";
+
+ fn result_value(&self) -> Value {
+ json!({
+ "code": Self::CODE,
+ "message": self.message.clone(),
+ "subject_account_id": self.subject_account_id.clone(),
+ "farm_config": {
+ "scope": self.farm_config_scope.clone(),
+ "path": self.farm_config_path.clone(),
+ },
+ "actions": self.actions.clone(),
+ })
+ }
+
+ fn output_warning(&self) -> OutputWarning {
+ OutputWarning {
+ code: Self::CODE.to_owned(),
+ message: self.message.clone(),
+ }
+ }
+}
+
+fn account_remove_farm_orphan_warning(
+ resolved: Option<&crate::runtime::farm_config::ResolvedFarmConfig>,
+ removed_account_id: &str,
+) -> Option<AccountRemoveFarmOrphanWarning> {
+ let resolved = resolved?;
+ if resolved.document.selection.account != removed_account_id {
+ return None;
+ }
+ Some(AccountRemoveFarmOrphanWarning {
+ message: format!(
+ "removed account `{removed_account_id}` is still bound as the farm seller account"
+ ),
+ subject_account_id: removed_account_id.to_owned(),
+ farm_config_scope: resolved.scope.as_str().to_owned(),
+ farm_config_path: resolved.path.display().to_string(),
+ actions: crate::runtime::farm::farm_bound_seller_recovery_actions("<selector>"),
+ })
+}
+
fn publish_recovery_actions(
config: &RuntimeConfig,
account: &AccountResolution,
diff --git a/src/out/terminal/renderers/account.rs b/src/out/terminal/renderers/account.rs
@@ -63,6 +63,7 @@ fn add_account_fields(document: &mut TerminalDocument, result: &Value) {
&["resolved_account", "account_id"][..],
&["default_account", "account_id"][..],
&["selected_account", "account_id"][..],
+ &["removed_account", "id"][..],
] {
if let Some(account_id) = common::string(result, path) {
common::push_field(document, "Account", account_id);
@@ -70,13 +71,26 @@ fn add_account_fields(document: &mut TerminalDocument, result: &Value) {
}
}
common::push_path_field(document, "Label", result, &["account", "label"]);
+ common::push_path_field(document, "Label", result, &["removed_account", "label"]);
common::push_path_field(document, "Public key", result, &["account", "public_key"]);
+ common::push_path_field(
+ document,
+ "Public key",
+ result,
+ &["removed_account", "public_key"],
+ );
common::push_bool_field(
document,
"Write capable",
result,
&["account", "write_capable"],
);
+ common::push_bool_field(
+ document,
+ "Write capable",
+ result,
+ &["removed_account", "write_capable"],
+ );
common::push_path_field(document, "Source", result, &["source"]);
}
diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs
@@ -498,6 +498,157 @@ fn account_remove_dry_run_validates_selector_without_mutating_store() {
}
#[test]
+fn account_remove_warns_when_farm_bound_seller_is_orphaned() {
+ let sandbox = RadrootsCliSandbox::new();
+ let first = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let first_account_id = first["result"]["account"]["id"]
+ .as_str()
+ .expect("first account id");
+ let farm = create_test_farm(&sandbox);
+ let farm_path = farm["result"]["config"]["path"]
+ .as_str()
+ .expect("farm path");
+ let farm_before_remove = fs::read_to_string(farm_path).expect("farm before account remove");
+ let second = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let second_account_id = second["result"]["account"]["id"]
+ .as_str()
+ .expect("second account id");
+
+ let non_bound_removed = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "account",
+ "remove",
+ second_account_id,
+ ]);
+
+ assert_eq!(non_bound_removed["operation_id"], "account.remove");
+ assert_eq!(non_bound_removed["result"]["state"], "removed");
+ assert_eq!(
+ non_bound_removed["result"]["removed_account"]["id"],
+ second_account_id
+ );
+ assert!(non_bound_removed["result"].get("warnings").is_none());
+ assert!(non_bound_removed["result"].get("actions").is_none());
+ assert!(
+ non_bound_removed["warnings"]
+ .as_array()
+ .expect("top-level warnings")
+ .is_empty()
+ );
+ assert!(
+ non_bound_removed["next_actions"]
+ .as_array()
+ .expect("next actions")
+ .is_empty()
+ );
+ assert_eq!(
+ fs::read_to_string(farm_path).expect("farm after non-bound remove"),
+ farm_before_remove
+ );
+
+ let orphaned = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "account",
+ "remove",
+ first_account_id,
+ ]);
+
+ assert_eq!(orphaned["operation_id"], "account.remove");
+ assert_eq!(orphaned["result"]["state"], "removed");
+ assert_eq!(
+ orphaned["result"]["removed_account"]["id"],
+ first_account_id
+ );
+ assert_eq!(
+ orphaned["result"]["warnings"][0]["code"],
+ "farm_bound_seller_orphaned"
+ );
+ assert_eq!(
+ orphaned["result"]["warnings"][0]["subject_account_id"],
+ first_account_id
+ );
+ assert_eq!(
+ orphaned["result"]["warnings"][0]["farm_config"]["scope"],
+ "workspace"
+ );
+ assert_eq!(
+ orphaned["result"]["warnings"][0]["farm_config"]["path"],
+ farm_path
+ );
+ assert_eq!(
+ orphaned["warnings"][0]["code"],
+ "farm_bound_seller_orphaned"
+ );
+ assert_action_present(&orphaned, "radroots account import <path>");
+ assert_action_present(&orphaned, "radroots --dry-run farm rebind <selector>");
+ assert_action_present(
+ &orphaned,
+ "radroots --approval-token approve farm rebind <selector>",
+ );
+ assert_next_action_present(&orphaned, "radroots account import <path>");
+ assert_next_action_present(&orphaned, "radroots --dry-run farm rebind <selector>");
+ assert_next_action_present(
+ &orphaned,
+ "radroots --approval-token approve farm rebind <selector>",
+ );
+ assert_eq!(
+ fs::read_to_string(farm_path).expect("farm after farm-bound remove"),
+ farm_before_remove
+ );
+}
+
+#[test]
+fn account_remove_farm_orphan_warning_renders_terminal() {
+ let sandbox = RadrootsCliSandbox::new();
+ let created = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let account_id = created["result"]["account"]["id"]
+ .as_str()
+ .expect("account id")
+ .to_owned();
+ create_test_farm(&sandbox);
+
+ let output = sandbox
+ .command()
+ .args([
+ "--approval-token",
+ "approve",
+ "account",
+ "remove",
+ account_id.as_str(),
+ ])
+ .output()
+ .expect("terminal account remove");
+
+ assert!(output.status.success());
+ assert!(output.stderr.is_empty());
+ let stdout = String::from_utf8(output.stdout).expect("terminal stdout");
+ assert!(stdout.contains("Account removed"), "{stdout}");
+ assert!(stdout.contains(account_id.as_str()), "{stdout}");
+ assert!(
+ stdout.contains("Warnings\n farm_bound_seller_orphaned:"),
+ "{stdout}"
+ );
+ assert!(
+ stdout.contains("Next\n radroots account import <path>"),
+ "{stdout}"
+ );
+ assert!(
+ stdout.contains("radroots --dry-run farm rebind <selector>"),
+ "{stdout}"
+ );
+ assert!(
+ stdout.contains("radroots --approval-token approve farm rebind <selector>"),
+ "{stdout}"
+ );
+}
+
+#[test]
fn account_selection_update_dry_run_validates_selector_without_mutating_selection() {
let sandbox = RadrootsCliSandbox::new();
let first = sandbox.json_success(&["--format", "json", "account", "create"]);
@@ -2634,3 +2785,24 @@ fn next_action_commands(value: &Value) -> Vec<&str> {
.filter_map(|entry| entry["command"].as_str())
.collect()
}
+
+fn create_test_farm(sandbox: &RadrootsCliSandbox) -> Value {
+ sandbox.json_success(&[
+ "--format",
+ "json",
+ "farm",
+ "create",
+ "--name",
+ "Green Farm",
+ "--location",
+ "farmstand",
+ "--city",
+ "San Francisco",
+ "--country",
+ "US",
+ "--geohash",
+ "9q8yy",
+ "--delivery-method",
+ "pickup",
+ ])
+}