diff --git a/src/NETWORK_SECURITY.md b/src/NETWORK_SECURITY.md index 4f3457e037..68d5d4f842 100644 --- a/src/NETWORK_SECURITY.md +++ b/src/NETWORK_SECURITY.md @@ -138,6 +138,10 @@ Configurable via `HTTP_HOST` (default `127.0.0.1`) and `HTTP_PORT` (default `808 By default the webhook server binds to loopback only. For external webhook providers, expose it intentionally via a tunnel or set `HTTP_HOST` explicitly. +**`--cli-only` mode:** When the `--cli-only` flag is set, non-CLI network services and channel activations are suppressed — no webhook server, WASM channel endpoints, HTTP channel, Signal channel, relay channel restoration, gateway channel, managed tunnel, or sandbox orchestrator API will start. + +This mode suppresses network-facing services; it does not disable the job tool surface. Scheduler-backed job tools remain available, while sandbox/container-backed job dependencies are only injected when the orchestrator is running. + **Reference:** `src/config/channels.rs` — `http_host` default (`"127.0.0.1"`), `http_port` default (`8080`) ### Authentication diff --git a/src/main.rs b/src/main.rs index 47f37777d7..d733d2d0c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,6 +84,13 @@ fn format_top_level_error(err: &anyhow::Error) { eprintln!(); } +/// Returns `true` when non-CLI network services should be enabled. +/// `--cli-only` suppresses all of them: webhooks, WASM channels, HTTP, +/// Signal, relay channels, gateway, managed tunnel, and sandbox orchestrator API. +fn non_cli_channels_enabled(cli_only: bool) -> bool { + !cli_only +} + fn normalize_persisted_wasm_channel_names(names: I) -> std::collections::HashSet where I: IntoIterator, @@ -134,6 +141,7 @@ fn persisted_non_relay_wasm_channel_names( async fn async_main() -> anyhow::Result<()> { let cli = Cli::parse(); + let enable_non_cli = non_cli_channels_enabled(cli.cli_only); // Handle non-agent commands first (they don't need full setup) match &cli.command { @@ -423,21 +431,39 @@ async fn async_main() -> anyhow::Result<()> { // ── Tunnel setup ─────────────────────────────────────────────────── - let (config, active_tunnel) = ironclaw::tunnel::start_managed_tunnel(config).await; + let (config, active_tunnel) = if enable_non_cli { + ironclaw::tunnel::start_managed_tunnel(config).await + } else { + (config, None) + }; // ── Orchestrator / container job manager ──────────────────────────── + // Orchestrator starts an internal HTTP API (default 0.0.0.0:50051) for + // sandbox worker communication. Skip it entirely under --cli-only to + // honour the "no network listeners" contract. - let orch = ironclaw::orchestrator::setup_orchestrator( - &config, - &components.llm, - components.db.as_ref(), - components.secrets_store.as_ref(), - ) - .await; - let container_job_manager = orch.container_job_manager; - let job_event_tx = orch.job_event_tx; - let prompt_queue = orch.prompt_queue; - let docker_status = orch.docker_status; + let (container_job_manager, job_event_tx, prompt_queue, docker_status) = if enable_non_cli { + let orch = ironclaw::orchestrator::setup_orchestrator( + &config, + &components.llm, + components.db.as_ref(), + components.secrets_store.as_ref(), + ) + .await; + ( + orch.container_job_manager, + orch.job_event_tx, + orch.prompt_queue, + orch.docker_status, + ) + } else { + ( + None, + None, + std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + ironclaw::sandbox::DockerStatus::Disabled, + ) + }; // Derive user-facing warning from docker_status for channel notification let docker_user_warning: Option = match docker_status { @@ -629,17 +655,20 @@ async fn async_main() -> anyhow::Result<()> { // Collect webhook route fragments; a single WebhookServer hosts them all. let mut webhook_routes: Vec = Vec::new(); - webhook_routes.push(webhooks::routes(ToolWebhookState { - tools: Arc::clone(&components.tools), - routine_engine: Arc::clone(&shared_routine_engine_slot), - user_id: config.owner_id.clone(), - secrets_store: components.secrets_store.clone(), - })); + if enable_non_cli { + webhook_routes.push(webhooks::routes(ToolWebhookState { + tools: Arc::clone(&components.tools), + routine_engine: Arc::clone(&shared_routine_engine_slot), + user_id: config.owner_id.clone(), + secrets_store: components.secrets_store.clone(), + })); + } // Load WASM channels and register their webhook routes. // Ensure the channels directory exists so the WASM runtime initializes even when // no channels are installed yet — hot-activation needs the runtime to be available. - if config.channels.wasm_channels_enabled + if enable_non_cli + && config.channels.wasm_channels_enabled && let Err(e) = std::fs::create_dir_all(&config.channels.wasm_channels_dir) { tracing::warn!( @@ -648,7 +677,10 @@ async fn async_main() -> anyhow::Result<()> { "Failed to create WASM channels directory" ); } - if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() { + if enable_non_cli + && config.channels.wasm_channels_enabled + && config.channels.wasm_channels_dir.exists() + { let wasm_result = ironclaw::channels::wasm::setup_wasm_channels( &config, &components.secrets_store, @@ -678,9 +710,7 @@ async fn async_main() -> anyhow::Result<()> { } // Add Signal channel if configured and not CLI-only mode. - if !cli.cli_only - && let Some(ref signal_config) = config.channels.signal - { + if enable_non_cli && let Some(ref signal_config) = config.channels.signal { let signal_channel = SignalChannel::new( signal_config.clone(), components.db.clone(), @@ -704,9 +734,7 @@ async fn async_main() -> anyhow::Result<()> { let mut webhook_server_addr: Option = None; #[cfg(unix)] let mut http_channel_state: Option> = None; - if !cli.cli_only - && let Some(ref http_config) = config.channels.http - { + if enable_non_cli && let Some(ref http_config) = config.channels.http { let http_channel = HttpChannel::new(http_config.clone()); #[cfg(unix)] { @@ -782,7 +810,8 @@ async fn async_main() -> anyhow::Result<()> { let scheduler_slot: ironclaw::tools::builtin::SchedulerSlot = Arc::new(tokio::sync::RwLock::new(None)); - // Register job tools (sandbox deps auto-injected when container_job_manager is available) + // Register job tools even under --cli-only so scheduler-backed jobs remain available. + // Sandbox-only dependencies are injected only when the container manager is running. components.tools.register_job_tools( Arc::clone(&components.context_manager), Some(scheduler_slot.clone()), @@ -790,7 +819,7 @@ async fn async_main() -> anyhow::Result<()> { components.db.clone(), job_event_tx.clone(), Some(channels.inject_sender()), - if config.sandbox.enabled { + if config.sandbox.enabled && container_job_manager.is_some() { Some(Arc::clone(&prompt_queue)) } else { None @@ -802,7 +831,7 @@ async fn async_main() -> anyhow::Result<()> { let mut gateway_url: Option = None; let mut sse_manager: Option> = None; - if let Some(ref gw_config) = config.channels.gateway { + if enable_non_cli && let Some(ref gw_config) = config.channels.gateway { let mut gw = GatewayChannel::new(gw_config.clone(), config.owner_id.clone()); gw = gw.with_llm_provider(Arc::clone(&components.llm)); if let Some(ref ws) = components.workspace { @@ -1137,9 +1166,9 @@ async fn async_main() -> anyhow::Result<()> { } } - // Ensure the relay channel manager is always set (even without WASM runtime), - // then restore any persisted relay channels. - if let Some(ref ext_mgr) = components.extension_manager { + // Relay restoration can make outbound relay calls and hot-add live channels. + // Suppress it under --cli-only along with other non-CLI channel activation paths. + if enable_non_cli && let Some(ref ext_mgr) = components.extension_manager { ext_mgr .set_relay_channel_manager(Arc::clone(&channels)) .await; @@ -1232,7 +1261,9 @@ async fn async_main() -> anyhow::Result<()> { document_extraction: Some(Arc::new( ironclaw::document_extraction::DocumentExtractionMiddleware::new(), )), - sandbox_readiness: if !config.sandbox.enabled { + sandbox_readiness: if !config.sandbox.enabled + || matches!(docker_status, ironclaw::sandbox::DockerStatus::Disabled) + { ironclaw::agent::routine_engine::SandboxReadiness::DisabledByConfig } else if docker_status.is_ok() { ironclaw::agent::routine_engine::SandboxReadiness::Available @@ -1543,6 +1574,138 @@ fn oauth_base_url(host: &str, port: u16) -> String { mod tests { use super::*; + /// Regression test for : + /// `--cli-only` must suppress webhook server and all non-CLI channels. + #[test] + fn cli_only_disables_non_cli_channels() { + assert!( + !non_cli_channels_enabled(true), + "--cli-only should disable non-CLI channels" + ); + assert!( + non_cli_channels_enabled(false), + "default mode should enable non-CLI channels" + ); + } + + fn find_bytes(haystack: &[u8], needle: &[u8], from: usize) -> Option { + haystack + .get(from..)? + .windows(needle.len()) + .position(|window| window == needle) + .map(|pos| from + pos) + } + + fn is_ident_byte(byte: u8) -> bool { + byte == b'_' || byte.is_ascii_alphanumeric() + } + + fn is_ident_at(source: &[u8], ident: &[u8], pos: usize) -> bool { + let before = pos.checked_sub(1).and_then(|idx| source.get(idx).copied()); + let after = source.get(pos + ident.len()).copied(); + !before.is_some_and(is_ident_byte) && !after.is_some_and(is_ident_byte) + } + + fn matching_close_brace(source: &[u8], open_pos: usize) -> Option { + let mut depth = 0usize; + for (idx, byte) in source[open_pos..].iter().enumerate() { + match byte { + b'{' => depth += 1, + b'}' => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(open_pos + idx); + } + } + _ => {} + } + } + None + } + + fn next_guard_block_start(source: &[u8], guard_pos: usize) -> Option { + let mut cursor = guard_pos + b"enable_non_cli".len(); + while let Some(byte) = source.get(cursor) { + match byte { + b'{' => return Some(cursor), + b';' | b'}' => return None, + _ => cursor += 1, + } + } + None + } + + fn is_inside_enable_non_cli_block(source: &[u8], pos: usize) -> bool { + let mut search_from = 0; + while let Some(guard_pos) = find_bytes(source, b"enable_non_cli", search_from) { + if guard_pos >= pos { + return false; + } + + if is_ident_at(source, b"enable_non_cli", guard_pos) + && let Some(open_pos) = next_guard_block_start(source, guard_pos) + && open_pos < pos + && let Some(close_pos) = matching_close_brace(source, open_pos) + && pos < close_pos + { + return true; + } + + search_from = guard_pos + b"enable_non_cli".len(); + } + false + } + + /// Source-level guard: every network-facing startup call in `async_main` + /// must be gated behind `enable_non_cli`. If someone adds a new channel + /// or tunnel call without the guard, this test fails. + /// + /// Regression coverage for . + #[test] + fn all_non_cli_ingress_paths_are_guarded() { + let source = include_str!("main.rs").as_bytes(); + + // Extract the body of async_main (from its signature to the end of file, + // minus the test module). Keep this byte-based to avoid UTF-8 boundary + // panics when checking source offsets. + let async_main_start = + find_bytes(source, b"async fn async_main(", 0).expect("async_main not found"); + let async_main_end = + find_bytes(source, b"#[cfg(test)]", async_main_start).unwrap_or(source.len()); + let async_main_body = &source[async_main_start..async_main_end]; + + // Calls that MUST be behind an `enable_non_cli` guard. + // When adding a new channel, tunnel, or network-facing service, + // add its startup call here so this test catches missing guards. + let guarded_calls = &[ + "setup_orchestrator(", + "webhooks::routes(", + "setup_wasm_channels(", + "SignalChannel::new(", + "HttpChannel::new(", + "GatewayChannel::new(", + "start_managed_tunnel(", + "set_relay_channel_manager(", + "restore_relay_channels(&", + ]; + + for call in guarded_calls { + let call = call.as_bytes(); + let mut search_from = 0; + while let Some(abs) = find_bytes(async_main_body, call, search_from) { + assert!( + is_inside_enable_non_cli_block(async_main_body, abs), + "Network ingress call `{}` at byte offset {} in async_main is not inside \ + a brace-delimited `enable_non_cli` block. Every non-CLI startup path must check \ + this flag (issue #1840).", + String::from_utf8_lossy(call), + abs, + ); + search_from = abs + call.len(); + } + } + } + #[test] fn oauth_base_url_maps_unspecified_to_localhost() { assert_eq!(oauth_base_url("0.0.0.0", 3033), "http://localhost:3033");