fix(cli): suppress non-CLI listeners under --cli-only (#1840) (#1869)

- Gate webhook, WASM, Signal, HTTP, gateway, tunnel, and orchestrator startup behind --cli-only
  - Restore the expected --cli-only contract so no non-CLI listeners bind or expose services
  - Prevent unintended network exposure from fallback listeners and managed tunnels
  - Stop registering job_prompt when no orchestrator is running to consume prompts
  - Fix sandbox readiness reporting under --cli-only so it reports disabled, not unavailable
  - Centralize the guard through non_cli_channels_enabled() for consistent startup behavior
  - Add regression coverage for unguarded network startup paths in async_main
  - Document --cli-only listener suppression in NETWORK_SECURITY.md
This commit is contained in:
Reid
2026-04-19 22:36:39 +08:00
committed by GitHub
parent 14333e4a0a
commit c854a214f9
2 changed files with 200 additions and 33 deletions

View File

@@ -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

View File

@@ -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<I, S>(names: I) -> std::collections::HashSet<String>
where
I: IntoIterator<Item = S>,
@@ -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<String> = 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<axum::Router> = 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<std::net::SocketAddr> = None;
#[cfg(unix)]
let mut http_channel_state: Option<Arc<ironclaw::channels::HttpChannelState>> = 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<String> = None;
let mut sse_manager: Option<std::sync::Arc<ironclaw::channels::web::sse::SseManager>> = 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 <https://github.com/nearai/ironclaw/issues/1840>:
/// `--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<usize> {
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<usize> {
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<usize> {
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 <https://github.com/nearai/ironclaw/issues/1840>.
#[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");