commit 6ba026c0f188b650567b5388611b279133d62cbe
parent f8f2c6c96506ce573a7003810495c3201089e23b
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Apr 2026 06:11:09 +0000
cli: enforce network posture globals
- reject external operations in offline mode
- require relay configuration for online network work
- preserve supported dry-run behavior while offline
- cover network posture in target cli tests
Diffstat:
3 files changed, 146 insertions(+), 4 deletions(-)
diff --git a/src/main.rs b/src/main.rs
@@ -22,8 +22,9 @@ use clap::Parser;
use crate::cli::{CliArgs, Command, ConfigArgs, ConfigCommand, OutputFormatArg};
use crate::operation_adapter::{
- MvpOperationRequest, OperationAdapter, OperationAdapterError, OperationOutputFormat,
- OperationRequest, OperationRequestPayload, OperationResultPayload, OperationService,
+ MvpOperationRequest, OperationAdapter, OperationAdapterError, OperationNetworkMode,
+ OperationOutputFormat, OperationRequest, OperationRequestPayload, OperationResultPayload,
+ OperationService,
};
use crate::operation_basket::BasketOperationService;
use crate::operation_core::CoreOperationService;
@@ -54,7 +55,7 @@ fn run() -> Result<ExitCode, runtime::RuntimeError> {
let config = RuntimeConfig::from_system(&config_args_from_target(&args)?)?;
let logging = initialize_logging(&config.logging)?;
let request = MvpOperationRequest::from_target_args(&args).map_err(operation_config_error)?;
- let envelope = match validate_request_contract(&request) {
+ let envelope = match validate_request_contract(&request, &config) {
Ok(()) => execute_request(request, &config, &logging),
Err(error) => failure_envelope(&request, error),
};
@@ -314,7 +315,10 @@ where
}
}
-fn validate_request_contract(request: &MvpOperationRequest) -> Result<(), OperationAdapterError> {
+fn validate_request_contract(
+ request: &MvpOperationRequest,
+ config: &RuntimeConfig,
+) -> Result<(), OperationAdapterError> {
let spec = request.spec();
if matches!(
request.context().output_format,
@@ -332,9 +336,61 @@ fn validate_request_contract(request: &MvpOperationRequest) -> Result<(), Operat
message: format!("`{}` does not support --dry-run", spec.cli_path),
});
}
+ validate_network_contract(request, config)?;
Ok(())
}
+fn validate_network_contract(
+ request: &MvpOperationRequest,
+ config: &RuntimeConfig,
+) -> Result<(), OperationAdapterError> {
+ let spec = request.spec();
+ let external = external_network_operation(spec.operation_id);
+ match request.context().network_mode {
+ OperationNetworkMode::Default => Ok(()),
+ OperationNetworkMode::Offline => {
+ if external && !request.context().dry_run {
+ return Err(OperationAdapterError::OfflineForbidden {
+ operation_id: spec.operation_id.to_owned(),
+ message: format!(
+ "`{}` requires relay, provider, or workflow network access",
+ spec.cli_path
+ ),
+ });
+ }
+ Ok(())
+ }
+ OperationNetworkMode::Online => {
+ if external && !request.context().dry_run && config.relay.urls.is_empty() {
+ return Err(OperationAdapterError::NetworkUnavailable {
+ operation_id: spec.operation_id.to_owned(),
+ message: format!(
+ "`{}` requires at least one configured relay for online execution",
+ spec.cli_path
+ ),
+ });
+ }
+ Ok(())
+ }
+ }
+}
+
+fn external_network_operation(operation_id: &str) -> bool {
+ matches!(
+ operation_id,
+ "sync.pull"
+ | "sync.push"
+ | "sync.watch"
+ | "market.refresh"
+ | "farm.publish"
+ | "listing.publish"
+ | "listing.archive"
+ | "order.submit"
+ | "order.event.watch"
+ | "job.watch"
+ )
+}
+
fn failure_envelope(request: &MvpOperationRequest, error: OperationAdapterError) -> OutputEnvelope {
OutputEnvelope::failure(
request.operation_id(),
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -302,6 +302,16 @@ pub enum OperationAdapterError {
operation_id: String,
message: String,
},
+ #[error("operation `{operation_id}` is forbidden while offline: {message}")]
+ OfflineForbidden {
+ operation_id: String,
+ message: String,
+ },
+ #[error("operation `{operation_id}` cannot run online: {message}")]
+ NetworkUnavailable {
+ operation_id: String,
+ message: String,
+ },
#[error("operation runtime error: {0}")]
Runtime(String),
#[error("operation `{operation_id}` is unavailable or unconfigured: {message}")]
@@ -329,6 +339,16 @@ impl OperationAdapterError {
Self::InvalidInput { message, .. } => {
OutputError::new("invalid_input", message.clone(), CliExitCode::InvalidInput)
}
+ Self::OfflineForbidden { message, .. } => OutputError::new(
+ "offline_forbidden",
+ message.clone(),
+ CliExitCode::SyncOrNetworkFailure,
+ ),
+ Self::NetworkUnavailable { message, .. } => OutputError::new(
+ "network_unavailable",
+ message.clone(),
+ CliExitCode::SyncOrNetworkFailure,
+ ),
Self::UnknownOperation(operation_id) => OutputError::new(
"unknown_operation",
format!("unknown operation `{operation_id}`"),
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -152,6 +152,72 @@ fn unsupported_ndjson_returns_structured_invalid_input() {
}
#[test]
+fn offline_forbids_external_network_operations() {
+ let output = radroots()
+ .args(["--format", "json", "--offline", "sync", "pull"])
+ .output()
+ .expect("run offline sync pull");
+
+ assert!(!output.status.success());
+ let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope");
+
+ assert_eq!(value["operation_id"], "sync.pull");
+ assert_eq!(value["result"], Value::Null);
+ assert_eq!(value["errors"][0]["code"], "offline_forbidden");
+ assert_eq!(value["errors"][0]["exit_code"], 8);
+}
+
+#[test]
+fn offline_allows_supported_external_dry_run() {
+ let sandbox = RadrootsCliSandbox::new();
+ let listing_file = sandbox.root().join("listing.toml");
+ let listing_file = listing_file.to_string_lossy().into_owned();
+
+ let publish = json_success(
+ &sandbox,
+ &[
+ "--format",
+ "json",
+ "--offline",
+ "--dry-run",
+ "listing",
+ "publish",
+ listing_file.as_str(),
+ ],
+ );
+
+ assert_eq!(publish["operation_id"], "listing.publish");
+ assert_eq!(publish["result"]["state"], "dry_run");
+}
+
+#[test]
+fn online_requires_relay_for_external_network_operations() {
+ let output = radroots()
+ .args(["--format", "json", "--online", "market", "refresh"])
+ .output()
+ .expect("run online market refresh");
+
+ assert!(!output.status.success());
+ let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope");
+
+ assert_eq!(value["operation_id"], "market.refresh");
+ assert_eq!(value["result"], Value::Null);
+ assert_eq!(value["errors"][0]["code"], "network_unavailable");
+ assert_eq!(value["errors"][0]["exit_code"], 8);
+}
+
+#[test]
+fn online_allows_local_diagnostics() {
+ let value = json_success(
+ &RadrootsCliSandbox::new(),
+ &["--format", "json", "--online", "workspace", "get"],
+ );
+
+ assert_eq!(value["operation_id"], "workspace.get");
+ assert_eq!(value["errors"].as_array().expect("errors").len(), 0);
+}
+
+#[test]
fn required_approval_missing_token_returns_structured_error() {
let output = radroots()
.args(["--format", "json", "order", "submit"])