Files
optimclaw/src/config/tunnel.rs
T
cf96a3253c fix(tests): replace hardcoded /tmp paths with tempdir + add 300 unit tests (#659)
* test: add unit tests across 20 modules for coverage push

Add 300+ unit tests covering config, context, evaluation, extensions,
LLM, secrets, tools/builder, and tools/mcp modules. All tests are
pure unit tests (no mocks) exercising serde roundtrips, edge cases,
error paths, and business logic.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(tests): replace hardcoded /tmp paths with tempfile::tempdir

The e2e_metrics_test::test_metrics_collected_from_tool_trace test was
failing because setup_test_dir() created /tmp/ironclaw_metrics_test but
the fixture referenced /tmp/ironclaw_e2e_test/hello.txt (path mismatch).

Added LlmTrace::replace_paths() to substitute fixture paths at runtime,
then converted all 12 test files from hardcoded /tmp/ironclaw_* paths to
tempfile::tempdir(). Tests are now isolated, parallel-safe, and leave no
debris on disk.

Regression test: test_metrics_collected_from_tool_trace now passes
consistently regardless of prior /tmp state.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-07 08:24:24 +00:00

319 lines
11 KiB
Rust

use crate::config::helpers::optional_env;
use crate::error::ConfigError;
use crate::settings::Settings;
/// Tunnel configuration for exposing the agent to the internet.
///
/// Used by channels and tools that need public webhook endpoints.
/// The tunnel URL is shared across all channels (Telegram, Slack, etc.).
///
/// Two modes:
/// - **Static URL** (`TUNNEL_URL`): set the public URL directly (manual tunnel)
/// - **Managed provider** (`TUNNEL_PROVIDER`): lifecycle-managed tunnel process
///
/// When a managed provider is configured _and_ no static URL is set,
/// the gateway starts the tunnel on boot and populates `public_url`.
#[derive(Debug, Clone, Default)]
pub struct TunnelConfig {
/// Public URL from tunnel provider (e.g., "https://abc123.ngrok.io").
/// Set statically via `TUNNEL_URL` or populated at runtime by a managed tunnel.
pub public_url: Option<String>,
/// Provider configuration for lifecycle-managed tunnels.
/// `None` when using a static URL or no tunnel at all.
pub provider: Option<crate::tunnel::TunnelProviderConfig>,
}
impl TunnelConfig {
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
let public_url = optional_env("TUNNEL_URL")?
.or_else(|| settings.tunnel.public_url.clone().filter(|s| !s.is_empty()));
if let Some(ref url) = public_url
&& !url.starts_with("https://")
{
return Err(ConfigError::InvalidValue {
key: "TUNNEL_URL".to_string(),
message: "must start with https:// (webhooks require HTTPS)".to_string(),
});
}
// Resolve managed tunnel provider config.
// Priority: env var > settings > default (none).
let provider_name = optional_env("TUNNEL_PROVIDER")?
.or_else(|| settings.tunnel.provider.clone())
.unwrap_or_default();
let provider = if provider_name.is_empty() || provider_name == "none" {
None
} else {
Some(crate::tunnel::TunnelProviderConfig {
provider: provider_name.clone(),
cloudflare: optional_env("TUNNEL_CF_TOKEN")?
.or_else(|| settings.tunnel.cf_token.clone())
.map(|token| crate::tunnel::CloudflareTunnelConfig { token }),
tailscale: Some(crate::tunnel::TailscaleTunnelConfig {
funnel: optional_env("TUNNEL_TS_FUNNEL")?
.map(|s| s == "true" || s == "1")
.unwrap_or(settings.tunnel.ts_funnel),
hostname: optional_env("TUNNEL_TS_HOSTNAME")?
.or_else(|| settings.tunnel.ts_hostname.clone()),
}),
ngrok: {
let ngrok_domain = optional_env("TUNNEL_NGROK_DOMAIN")?
.or_else(|| settings.tunnel.ngrok_domain.clone());
optional_env("TUNNEL_NGROK_TOKEN")?
.or_else(|| settings.tunnel.ngrok_token.clone())
.map(|auth_token| crate::tunnel::NgrokTunnelConfig {
auth_token,
domain: ngrok_domain,
})
},
custom: {
let health_url = optional_env("TUNNEL_CUSTOM_HEALTH_URL")?
.or_else(|| settings.tunnel.custom_health_url.clone());
let url_pattern = optional_env("TUNNEL_CUSTOM_URL_PATTERN")?
.or_else(|| settings.tunnel.custom_url_pattern.clone());
optional_env("TUNNEL_CUSTOM_COMMAND")?
.or_else(|| settings.tunnel.custom_command.clone())
.map(|start_command| crate::tunnel::CustomTunnelConfig {
start_command,
health_url,
url_pattern,
})
},
})
};
Ok(Self {
public_url,
provider,
})
}
/// Check if a tunnel is configured (static URL or managed provider).
pub fn is_enabled(&self) -> bool {
self.public_url.is_some() || self.provider.is_some()
}
/// Get the webhook URL for a given path.
pub fn webhook_url(&self, path: &str) -> Option<String> {
self.public_url.as_ref().map(|base| {
let base = base.trim_end_matches('/');
let path = path.trim_start_matches('/');
format!("{}/{}", base, path)
})
}
}
#[cfg(test)]
mod tests {
use crate::config::tunnel::TunnelConfig;
use crate::tunnel::{
CloudflareTunnelConfig, CustomTunnelConfig, NgrokTunnelConfig, TailscaleTunnelConfig,
TunnelProviderConfig,
};
// ── Default ─────────────────────────────────────────────────────
#[test]
fn default_is_disabled() {
let cfg = TunnelConfig::default();
assert!(cfg.public_url.is_none());
assert!(cfg.provider.is_none());
assert!(!cfg.is_enabled());
}
// ── is_enabled ──────────────────────────────────────────────────
#[test]
fn is_enabled_with_static_url() {
let cfg = TunnelConfig {
public_url: Some("https://tunnel.example.com".to_string()),
provider: None,
};
assert!(cfg.is_enabled());
}
#[test]
fn is_enabled_with_provider() {
let cfg = TunnelConfig {
public_url: None,
provider: Some(TunnelProviderConfig {
provider: "cloudflare".to_string(),
cloudflare: Some(CloudflareTunnelConfig {
token: "cf-tok".to_string(),
}),
tailscale: None,
ngrok: None,
custom: None,
}),
};
assert!(cfg.is_enabled());
}
#[test]
fn is_enabled_with_both() {
let cfg = TunnelConfig {
public_url: Some("https://example.com".to_string()),
provider: Some(TunnelProviderConfig {
provider: "ngrok".to_string(),
cloudflare: None,
tailscale: None,
ngrok: Some(NgrokTunnelConfig {
auth_token: "ngrok-tok".to_string(),
domain: None,
}),
custom: None,
}),
};
assert!(cfg.is_enabled());
}
// ── webhook_url ─────────────────────────────────────────────────
#[test]
fn webhook_url_none_when_no_public_url() {
let cfg = TunnelConfig::default();
assert!(cfg.webhook_url("/hook").is_none());
}
#[test]
fn webhook_url_basic() {
let cfg = TunnelConfig {
public_url: Some("https://abc.ngrok.io".to_string()),
provider: None,
};
assert_eq!(
cfg.webhook_url("/webhook/telegram"),
Some("https://abc.ngrok.io/webhook/telegram".to_string())
);
}
#[test]
fn webhook_url_trims_trailing_slash_on_base() {
let cfg = TunnelConfig {
public_url: Some("https://abc.ngrok.io/".to_string()),
provider: None,
};
assert_eq!(
cfg.webhook_url("/hook"),
Some("https://abc.ngrok.io/hook".to_string())
);
}
#[test]
fn webhook_url_trims_leading_slash_on_path() {
let cfg = TunnelConfig {
public_url: Some("https://abc.ngrok.io".to_string()),
provider: None,
};
// Path without leading slash should also work
assert_eq!(
cfg.webhook_url("hook"),
Some("https://abc.ngrok.io/hook".to_string())
);
}
#[test]
fn webhook_url_double_slash_normalization() {
let cfg = TunnelConfig {
public_url: Some("https://abc.ngrok.io/".to_string()),
provider: None,
};
// Both base trailing and path leading slashes trimmed
assert_eq!(
cfg.webhook_url("/api/webhook"),
Some("https://abc.ngrok.io/api/webhook".to_string())
);
}
#[test]
fn webhook_url_empty_path() {
let cfg = TunnelConfig {
public_url: Some("https://abc.ngrok.io".to_string()),
provider: None,
};
assert_eq!(
cfg.webhook_url(""),
Some("https://abc.ngrok.io/".to_string())
);
}
// ── TunnelProviderConfig field coverage ─────────────────────────
#[test]
fn provider_config_cloudflare() {
let p = TunnelProviderConfig {
provider: "cloudflare".to_string(),
cloudflare: Some(CloudflareTunnelConfig {
token: "cf-secret".to_string(),
}),
tailscale: None,
ngrok: None,
custom: None,
};
assert_eq!(p.provider, "cloudflare");
assert_eq!(p.cloudflare.as_ref().unwrap().token, "cf-secret");
}
#[test]
fn provider_config_tailscale() {
let ts = TailscaleTunnelConfig {
funnel: true,
hostname: Some("my-host".to_string()),
};
assert!(ts.funnel);
assert_eq!(ts.hostname.as_deref(), Some("my-host"));
}
#[test]
fn provider_config_tailscale_defaults() {
let ts = TailscaleTunnelConfig::default();
assert!(!ts.funnel);
assert!(ts.hostname.is_none());
}
#[test]
fn provider_config_ngrok() {
let ng = NgrokTunnelConfig {
auth_token: "ng-tok".to_string(),
domain: Some("custom.ngrok.dev".to_string()),
};
assert_eq!(ng.auth_token, "ng-tok");
assert_eq!(ng.domain.as_deref(), Some("custom.ngrok.dev"));
}
#[test]
fn provider_config_ngrok_defaults() {
let ng = NgrokTunnelConfig::default();
assert!(ng.auth_token.is_empty());
assert!(ng.domain.is_none());
}
#[test]
fn provider_config_custom() {
let c = CustomTunnelConfig {
start_command: "bore local {port}".to_string(),
health_url: Some("http://localhost:8080/health".to_string()),
url_pattern: Some("https://bore.pub".to_string()),
};
assert_eq!(c.start_command, "bore local {port}");
assert!(c.health_url.is_some());
assert!(c.url_pattern.is_some());
}
#[test]
fn provider_config_custom_defaults() {
let c = CustomTunnelConfig::default();
assert!(c.start_command.is_empty());
assert!(c.health_url.is_none());
assert!(c.url_pattern.is_none());
}
#[test]
fn cloudflare_config_defaults() {
let cf = CloudflareTunnelConfig::default();
assert!(cf.token.is_empty());
}
}