mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-02 23:56:24 +08:00
- 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:
@@ -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
|
||||
|
||||
229
src/main.rs
229
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<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");
|
||||
|
||||
Reference in New Issue
Block a user