commit 733b4d1d389543b296e86640504e8ddbc76eabb0
parent f369dd7a63330630dbe46da6f59131b672441e5f
Author: triesap <tyson@radroots.org>
Date: Wed, 8 Jul 2026 05:23:45 +0000
transport: validate proxy token readiness
Require proxy token sources to materialize before CLI profile persistence, status readiness, routed operation gates, and SDK proxy profile construction. Add redacted readiness coverage for missing files, missing secret ids, empty tokens, control-character tokens, and valid file or secret sources.
Diffstat:
5 files changed, 355 insertions(+), 61 deletions(-)
diff --git a/src/main.rs b/src/main.rs
@@ -453,13 +453,13 @@ fn validate_network_contract(
} = requirement
&& (!request.context().dry_run || dry_run_requires_network)
&& requires_pre_runtime_transport_target(spec.operation_id)
- && !transport_profile_is_usable_for_delivery(config)
+ && let Some(reason) = transport_profile_delivery_unavailable_reason(config)
{
return Err(OperationAdapterError::NetworkUnavailable {
operation_id: spec.operation_id.to_owned(),
message: format!(
- "`{}` requires a delivery-capable transport profile for online execution",
- spec.cli_path
+ "`{}` requires a delivery-capable transport profile for online execution: {reason}",
+ spec.cli_path,
),
});
}
@@ -527,14 +527,13 @@ fn validate_transport_profile_contract(
});
}
if matches!(config.transport.profile, TransportProfileKind::Proxy)
- && config.transport.proxy.token_file.is_none()
- && config.transport.proxy.token_secret_id.is_none()
+ && let Err(error) = runtime::transport::proxy_token_ready(config)
{
return Err(OperationAdapterError::NetworkUnavailable {
operation_id: spec.operation_id.to_owned(),
message: format!(
- "`{}` requires a configured proxy token file or token secret id",
- spec.cli_path
+ "`{}` requires a usable proxy token source: {error}",
+ spec.cli_path,
),
});
}
@@ -552,14 +551,23 @@ fn is_transport_profile_routed_operation(operation_id: &str) -> bool {
)
}
-fn transport_profile_is_usable_for_delivery(config: &RuntimeConfig) -> bool {
+fn transport_profile_delivery_unavailable_reason(config: &RuntimeConfig) -> Option<String> {
match config.transport.profile {
- TransportProfileKind::Nostr => !config.transport.nostr_relay_urls.is_empty(),
- TransportProfileKind::Proxy => {
- config.transport.proxy.token_file.is_some()
- || config.transport.proxy.token_secret_id.is_some()
+ TransportProfileKind::Nostr => {
+ config.transport.nostr_relay_urls.is_empty().then(|| {
+ "active Nostr transport profile has no configured relay targets".to_owned()
+ })
+ }
+ TransportProfileKind::Proxy => match runtime::transport::proxy_token_ready(config) {
+ Ok(()) => None,
+ Err(error) => Some(error.to_string()),
+ },
+ TransportProfileKind::LocalOnly => {
+ Some("active local_only transport profile cannot deliver".to_owned())
+ }
+ TransportProfileKind::ReticulumPreview => {
+ Some("active reticulum_preview transport profile cannot deliver".to_owned())
}
- TransportProfileKind::LocalOnly | TransportProfileKind::ReticulumPreview => false,
}
}
diff --git a/src/ops/exec/core.rs b/src/ops/exec/core.rs
@@ -935,17 +935,8 @@ fn nostr_publish_readiness(
}
fn proxy_publish_readiness(config: &RuntimeConfig) -> (&'static str, bool, Option<String>) {
- if config.transport.proxy.token_file.is_none()
- && config.transport.proxy.token_secret_id.is_none()
- {
- return (
- "unconfigured",
- false,
- Some(
- "proxy transport profile requires a configured token file or token secret id"
- .to_owned(),
- ),
- );
+ if let Err(error) = crate::runtime::transport::proxy_token_ready(config) {
+ return ("unconfigured", false, Some(error.to_string()));
}
if matches!(config.signer.backend, SignerBackend::Myc) {
@@ -1130,7 +1121,7 @@ fn publish_recovery_actions(
}
}
TransportProfileKind::Proxy => {
- if self::proxy_token_configured(config) {
+ if crate::runtime::transport::proxy_token_ready(config).is_ok() {
if publish.signed_write_required
&& matches!(config.signer.backend, SignerBackend::Myc)
{
@@ -1159,10 +1150,6 @@ fn publish_recovery_actions(
actions
}
-fn proxy_token_configured(config: &RuntimeConfig) -> bool {
- config.transport.proxy.token_file.is_some() || config.transport.proxy.token_secret_id.is_some()
-}
-
fn push_unique(actions: &mut Vec<String>, action: impl Into<String>) {
let action = action.into();
if !actions.contains(&action) {
diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs
@@ -646,16 +646,18 @@ fn sdk_transport_profile(config: &RuntimeConfig) -> Result<TransportProfile, Run
))
}
TransportProfileKind::Proxy => {
- let mut profile = ProxyProfile::new(config.transport.proxy.url.clone());
- if let Some(token) = proxy_bearer_token(config)? {
- profile = profile.with_bearer_token(token);
- }
+ let profile = ProxyProfile::new(config.transport.proxy.url.clone())
+ .with_bearer_token(proxy_bearer_token(config)?);
Ok(TransportProfile::proxy(profile))
}
}
}
-fn proxy_bearer_token(config: &RuntimeConfig) -> Result<Option<String>, RuntimeError> {
+pub(crate) fn validate_proxy_bearer_token(config: &RuntimeConfig) -> Result<(), RuntimeError> {
+ proxy_bearer_token(config).map(|_| ())
+}
+
+fn proxy_bearer_token(config: &RuntimeConfig) -> Result<String, RuntimeError> {
if let Some(path) = config.transport.proxy.token_file.as_ref() {
let token = fs::read_to_string(path).map_err(|error| {
RuntimeError::Config(format!(
@@ -666,8 +668,7 @@ fn proxy_bearer_token(config: &RuntimeConfig) -> Result<Option<String>, RuntimeE
return normalize_proxy_bearer_token(
token.as_str(),
format!("proxy token file {}", path.display()).as_str(),
- )
- .map(Some);
+ );
}
if let Some(secret_id) = config.transport.proxy.token_secret_id.as_ref() {
@@ -683,11 +684,12 @@ fn proxy_bearer_token(config: &RuntimeConfig) -> Result<Option<String>, RuntimeE
return normalize_proxy_bearer_token(
token.as_str(),
format!("proxy token secret `{secret_id}`").as_str(),
- )
- .map(Some);
+ );
}
- Ok(None)
+ Err(RuntimeError::Config(
+ "proxy transport profile requires a configured token file or token secret id".to_owned(),
+ ))
}
fn normalize_proxy_bearer_token(raw: &str, source: &str) -> Result<String, RuntimeError> {
@@ -1223,6 +1225,20 @@ mod tests {
}
#[test]
+ fn proxy_sdk_profile_requires_materialized_bearer_token() {
+ let root = tempdir().expect("tempdir");
+ let mut config = sample_config(root.path(), Vec::new());
+ config.transport.profile = TransportProfileKind::Proxy;
+ config.transport.proxy.url = "http://127.0.0.1:7070".to_owned();
+
+ assert!(matches!(
+ CliSdkConfig::from_runtime_config(&config),
+ Err(RuntimeError::Config(message))
+ if message.contains("configured token file or token secret id")
+ ));
+ }
+
+ #[test]
fn proxy_token_resolution_rejects_empty_or_header_unsafe_tokens() {
assert!(matches!(
normalize_proxy_bearer_token(" \n", "proxy token"),
diff --git a/src/runtime/transport.rs b/src/runtime/transport.rs
@@ -1,4 +1,5 @@
use std::fs;
+use std::path::PathBuf;
use radroots_sdk::{PushOutboxRequest, SyncStatusRequest};
use radroots_transport::RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE;
@@ -57,6 +58,12 @@ pub fn set_profile(
let token_file = string_input(input, "proxy_token_file").map(str::to_owned);
let token_secret_id = string_input(input, "proxy_token_secret_id").map(str::to_owned);
validate_proxy_token_source(token_file.as_deref(), token_secret_id.as_deref())?;
+ validate_proxy_token_material(
+ config,
+ url,
+ token_file.as_deref(),
+ token_secret_id.as_deref(),
+ )?;
let mut proxy = Map::new();
proxy.insert("url".to_owned(), Value::String(url.to_owned()));
if let Some(token_file) = token_file.as_ref() {
@@ -225,24 +232,31 @@ fn active_profile_view(config: &RuntimeConfig) -> TransportProfileView {
None,
"preview_unavailable",
),
- TransportProfileKind::Proxy => profile_view_from_parts(
- "proxy",
- Vec::new(),
- None,
- Some(config.transport.proxy.url.clone()),
- config
- .transport
- .proxy
- .token_file
- .as_ref()
- .map(|path| path.display().to_string()),
- config.transport.proxy.token_secret_id.clone(),
- if proxy_token_configured(config) {
- "configured"
- } else {
- "unconfigured"
- },
- ),
+ TransportProfileKind::Proxy => {
+ let proxy_readiness = proxy_token_ready(config);
+ profile_view_from_parts(
+ "proxy",
+ Vec::new(),
+ None,
+ Some(config.transport.proxy.url.clone()),
+ config
+ .transport
+ .proxy
+ .token_file
+ .as_ref()
+ .map(|path| path.display().to_string()),
+ config.transport.proxy.token_secret_id.clone(),
+ if proxy_readiness.is_ok() {
+ "configured"
+ } else {
+ "unconfigured"
+ },
+ )
+ .with_message(proxy_readiness.err().map_or_else(
+ || "Proxy transport delegates delivery to the configured endpoint".to_owned(),
+ |error| error.to_string(),
+ ))
+ }
}
}
@@ -354,8 +368,33 @@ fn validate_proxy_token_source(
}
}
-fn proxy_token_configured(config: &RuntimeConfig) -> bool {
- config.transport.proxy.token_file.is_some() || config.transport.proxy.token_secret_id.is_some()
+pub fn proxy_token_ready(config: &RuntimeConfig) -> Result<(), RuntimeError> {
+ crate::runtime::sdk::validate_proxy_bearer_token(config)
+}
+
+fn validate_proxy_token_material(
+ config: &RuntimeConfig,
+ url: &str,
+ token_file: Option<&str>,
+ token_secret_id: Option<&str>,
+) -> Result<(), RuntimeError> {
+ let mut validation_config = config.clone();
+ validation_config.transport.profile = TransportProfileKind::Proxy;
+ validation_config.transport.proxy.url = url.to_owned();
+ validation_config.transport.proxy.token_file = token_file.map(PathBuf::from);
+ validation_config.transport.proxy.token_secret_id = token_secret_id.map(str::to_owned);
+ proxy_token_ready(&validation_config)
+}
+
+trait TransportProfileViewMessage {
+ fn with_message(self, message: String) -> Self;
+}
+
+impl TransportProfileViewMessage for TransportProfileView {
+ fn with_message(mut self, message: String) -> Self {
+ self.message = message;
+ self
+ }
}
pub(crate) fn update_app_config_table(
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -1593,6 +1593,120 @@ fn config_get_proxy_with_token_file_reports_ready_transport() {
}
#[test]
+fn config_get_proxy_with_token_secret_id_reports_ready_transport() {
+ let sandbox = RadrootsCliSandbox::new();
+ store_test_session_secret(&sandbox, "proxy.publish.token", "proxy_secret_token");
+ write_proxy_transport_config(&sandbox, "token_secret_id = \"proxy.publish.token\"\n");
+
+ let value = sandbox.json_success(&["--format", "json", "config", "get"]);
+
+ assert_eq!(value["operation_id"], "config.get");
+ assert_eq!(value["result"]["publish"]["transport"], "proxy");
+ assert_eq!(value["result"]["publish"]["state"], "ready");
+ assert_eq!(value["result"]["publish"]["executable"], true);
+ assert_eq!(value["result"]["transport"]["profile_id"], "proxy");
+ assert_eq!(value["result"]["transport"]["state"], "configured");
+ assert_eq!(value["result"]["transport"]["usable_for_delivery"], true);
+ assert_eq!(
+ value["result"]["transport"]["proxy_token_source"],
+ "token_secret_id"
+ );
+ assert_eq!(
+ value["result"]["transport"]["proxy_token_secret_id"],
+ "proxy.publish.token"
+ );
+ assert!(
+ !serde_json::to_string(&value)
+ .expect("json")
+ .contains("proxy_secret_token")
+ );
+}
+
+#[test]
+fn config_get_proxy_reports_redacted_token_material_failures() {
+ let missing_file = RadrootsCliSandbox::new();
+ let missing_path = missing_file.root().join("missing.proxy.token");
+ write_proxy_transport_config(
+ &missing_file,
+ format!(
+ "token_file = \"{}\"\n",
+ toml_string(missing_path.display().to_string().as_str())
+ )
+ .as_str(),
+ );
+ let missing_file_value = missing_file.json_success(&["--format", "json", "config", "get"]);
+ assert_eq!(
+ missing_file_value["result"]["publish"]["state"],
+ "unconfigured"
+ );
+ assert_contains(
+ &missing_file_value["result"]["publish"]["reason"],
+ "failed to read proxy token file",
+ );
+ assert_contains(
+ &missing_file_value["result"]["transport"]["message"],
+ missing_path.display().to_string().as_str(),
+ );
+
+ let missing_secret = RadrootsCliSandbox::new();
+ write_proxy_transport_config(
+ &missing_secret,
+ "token_secret_id = \"proxy.publish.missing\"\n",
+ );
+ let missing_secret_value = missing_secret.json_success(&["--format", "json", "config", "get"]);
+ assert_eq!(
+ missing_secret_value["result"]["publish"]["state"],
+ "unconfigured"
+ );
+ assert_contains(
+ &missing_secret_value["result"]["publish"]["reason"],
+ "proxy token secret `proxy.publish.missing` was not found",
+ );
+
+ let empty_file = RadrootsCliSandbox::new();
+ let empty_path = empty_file.root().join("empty.proxy.token");
+ fs::write(&empty_path, "\n").expect("write empty proxy token");
+ write_proxy_transport_config(
+ &empty_file,
+ format!(
+ "token_file = \"{}\"\n",
+ toml_string(empty_path.display().to_string().as_str())
+ )
+ .as_str(),
+ );
+ let empty_value = empty_file.json_success(&["--format", "json", "config", "get"]);
+ assert_eq!(empty_value["result"]["publish"]["state"], "unconfigured");
+ assert_contains(
+ &empty_value["result"]["publish"]["reason"],
+ "proxy token file",
+ );
+ assert_contains(&empty_value["result"]["publish"]["reason"], "is empty");
+
+ let control_file = RadrootsCliSandbox::new();
+ let control_path = control_file.root().join("control.proxy.token");
+ fs::write(&control_path, "proxy_control\tsecret").expect("write control proxy token");
+ write_proxy_transport_config(
+ &control_file,
+ format!(
+ "token_file = \"{}\"\n",
+ toml_string(control_path.display().to_string().as_str())
+ )
+ .as_str(),
+ );
+ let control_value = control_file.json_success(&["--format", "json", "config", "get"]);
+ assert_eq!(control_value["result"]["publish"]["state"], "unconfigured");
+ assert_contains(
+ &control_value["result"]["publish"]["reason"],
+ "contains unsupported control characters",
+ );
+ assert!(
+ !serde_json::to_string(&control_value)
+ .expect("json")
+ .contains("proxy_control")
+ );
+}
+
+#[test]
fn transport_profile_set_proxy_persists_token_file_without_printing_token_material() {
let sandbox = RadrootsCliSandbox::new();
let token_file = proxy_token_file(&sandbox);
@@ -1644,6 +1758,7 @@ fn transport_profile_set_proxy_persists_token_file_without_printing_token_materi
fn transport_profile_set_proxy_persists_token_secret_id_without_printing_token_material() {
let sandbox = RadrootsCliSandbox::new();
let token_file = proxy_token_file(&sandbox);
+ store_test_session_secret(&sandbox, "proxy.publish.token", "proxy_secret_token");
let value = sandbox.json_success(&[
"--format",
@@ -1672,7 +1787,7 @@ fn transport_profile_set_proxy_persists_token_secret_id_without_printing_token_m
assert!(
!serde_json::to_string(&value)
.expect("json")
- .contains("proxy_test_token")
+ .contains("proxy_secret_token")
);
let get = sandbox.json_success(&["--format", "json", "transport", "profile", "get"]);
@@ -1684,7 +1799,7 @@ fn transport_profile_set_proxy_persists_token_secret_id_without_printing_token_m
assert!(
!serde_json::to_string(&get)
.expect("json")
- .contains("proxy_test_token")
+ .contains("proxy_secret_token")
);
assert!(token_file.exists());
}
@@ -1739,6 +1854,135 @@ fn transport_profile_set_proxy_rejects_missing_and_conflicting_token_sources() {
}
#[test]
+fn transport_profile_set_proxy_rejects_unmaterializable_token_sources() {
+ let missing_file = RadrootsCliSandbox::new();
+ let missing_path = missing_file.root().join("missing.profile.token");
+ let (missing_file_output, missing_file_value) = missing_file.json_output(&[
+ "--format",
+ "json",
+ "transport",
+ "profile",
+ "set",
+ "--kind",
+ "proxy",
+ "--proxy-url",
+ "http://127.0.0.1:7070",
+ "--proxy-token-file",
+ missing_path.to_string_lossy().as_ref(),
+ ]);
+ assert!(!missing_file_output.status.success());
+ assert_contains(
+ &missing_file_value["errors"][0]["message"],
+ "failed to read proxy token file",
+ );
+
+ let missing_secret = RadrootsCliSandbox::new();
+ let (missing_secret_output, missing_secret_value) = missing_secret.json_output(&[
+ "--format",
+ "json",
+ "transport",
+ "profile",
+ "set",
+ "--kind",
+ "proxy",
+ "--proxy-url",
+ "http://127.0.0.1:7070",
+ "--proxy-token-secret-id",
+ "proxy.profile.missing",
+ ]);
+ assert!(!missing_secret_output.status.success());
+ assert_contains(
+ &missing_secret_value["errors"][0]["message"],
+ "proxy token secret `proxy.profile.missing` was not found",
+ );
+
+ let empty_file = RadrootsCliSandbox::new();
+ let empty_path = empty_file.root().join("empty.profile.token");
+ fs::write(&empty_path, "\n").expect("write empty profile token");
+ let (empty_output, empty_value) = empty_file.json_output(&[
+ "--format",
+ "json",
+ "transport",
+ "profile",
+ "set",
+ "--kind",
+ "proxy",
+ "--proxy-url",
+ "http://127.0.0.1:7070",
+ "--proxy-token-file",
+ empty_path.to_string_lossy().as_ref(),
+ ]);
+ assert!(!empty_output.status.success());
+ assert_contains(&empty_value["errors"][0]["message"], "is empty");
+
+ let control_file = RadrootsCliSandbox::new();
+ let control_path = control_file.root().join("control.profile.token");
+ fs::write(&control_path, "profile_control\tsecret").expect("write control profile token");
+ let (control_output, control_value) = control_file.json_output(&[
+ "--format",
+ "json",
+ "transport",
+ "profile",
+ "set",
+ "--kind",
+ "proxy",
+ "--proxy-url",
+ "http://127.0.0.1:7070",
+ "--proxy-token-file",
+ control_path.to_string_lossy().as_ref(),
+ ]);
+ assert!(!control_output.status.success());
+ assert_contains(
+ &control_value["errors"][0]["message"],
+ "contains unsupported control characters",
+ );
+ assert!(
+ !serde_json::to_string(&control_value)
+ .expect("json")
+ .contains("profile_control")
+ );
+}
+
+#[test]
+fn routed_proxy_operations_validate_token_material_before_sdk_runtime() {
+ let sandbox = RadrootsCliSandbox::new();
+ let missing_path = sandbox.root().join("missing.routed.token");
+ write_proxy_transport_config(
+ &sandbox,
+ format!(
+ "token_file = \"{}\"\n",
+ toml_string(missing_path.display().to_string().as_str())
+ )
+ .as_str(),
+ );
+
+ let (online_output, online_value) =
+ sandbox.json_output(&["--format", "json", "--online", "sync", "push"]);
+ assert!(!online_output.status.success());
+ assert_eq!(online_value["errors"][0]["code"], "network_unavailable");
+ assert_contains(
+ &online_value["errors"][0]["message"],
+ "failed to read proxy token file",
+ );
+
+ let (routed_output, routed_value) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "transport",
+ "outbox",
+ "push",
+ ]);
+ assert!(!routed_output.status.success());
+ assert_eq!(routed_value["errors"][0]["code"], "network_unavailable");
+ assert_contains(
+ &routed_value["errors"][0]["message"],
+ "failed to read proxy token file",
+ );
+}
+
+#[test]
fn config_get_rejects_proxy_transport_config_with_conflicting_token_sources() {
let sandbox = RadrootsCliSandbox::new();
let token_file = proxy_token_file(&sandbox);