Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87b5bca449 | |||
| be40c0c5a5 | |||
| 6527871928 | |||
| 0bda80de9c |
@@ -122,6 +122,34 @@ tools = ["mcp_browser_*"]
|
||||
keywords = ["browse", "navigate", "open url", "screenshot"]
|
||||
```
|
||||
|
||||
## `[pacing]`
|
||||
|
||||
Pacing controls for slow/local LLM workloads (Ollama, llama.cpp, vLLM). All keys are optional; when absent, existing behavior is preserved.
|
||||
|
||||
| Key | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `step_timeout_secs` | _none_ | Per-step timeout: maximum seconds for a single LLM inference turn. Catches a truly hung model without terminating the overall task loop |
|
||||
| `loop_detection_min_elapsed_secs` | _none_ | Minimum elapsed seconds before loop detection activates. Tasks completing under this threshold get aggressive loop protection; longer-running tasks receive a grace period |
|
||||
| `loop_ignore_tools` | `[]` | Tool names excluded from identical-output loop detection. Useful for browser workflows where `browser_screenshot` structurally resembles a loop |
|
||||
| `message_timeout_scale_max` | `4` | Override for the hardcoded timeout scaling cap. The channel message timeout budget is `message_timeout_secs * min(max_tool_iterations, message_timeout_scale_max)` |
|
||||
|
||||
Notes:
|
||||
|
||||
- These settings are intended for local/slow LLM deployments. Cloud-provider users typically do not need them.
|
||||
- `step_timeout_secs` operates independently of the total channel message timeout budget. A step timeout abort does not consume the overall budget; the loop simply stops.
|
||||
- `loop_detection_min_elapsed_secs` delays loop-detection counting, not the task itself. Loop protection remains fully active for short tasks (the default).
|
||||
- `loop_ignore_tools` only suppresses tool-output-based loop detection for the listed tools. Other safety features (max iterations, overall timeout) remain active.
|
||||
- `message_timeout_scale_max` must be >= 1. Setting it higher than `max_tool_iterations` has no additional effect (the formula uses `min()`).
|
||||
- Example configuration for a slow local Ollama deployment:
|
||||
|
||||
```toml
|
||||
[pacing]
|
||||
step_timeout_secs = 120
|
||||
loop_detection_min_elapsed_secs = 60
|
||||
loop_ignore_tools = ["browser_screenshot", "browser_navigate"]
|
||||
message_timeout_scale_max = 8
|
||||
```
|
||||
|
||||
## `[security.otp]`
|
||||
|
||||
| Key | Default | Purpose |
|
||||
@@ -425,6 +453,12 @@ Notes:
|
||||
| `port` | `42617` | gateway listen port |
|
||||
| `require_pairing` | `true` | require pairing before bearer auth |
|
||||
| `allow_public_bind` | `false` | block accidental public exposure |
|
||||
| `path_prefix` | _(none)_ | URL path prefix for reverse-proxy deployments (e.g. `"/zeroclaw"`) |
|
||||
|
||||
When deploying behind a reverse proxy that maps ZeroClaw to a sub-path,
|
||||
set `path_prefix` to that sub-path (e.g. `"/zeroclaw"`). All gateway
|
||||
routes will be served under this prefix. The value must start with `/`
|
||||
and must not end with `/`.
|
||||
|
||||
## `[autonomy]`
|
||||
|
||||
@@ -597,7 +631,7 @@ Top-level channel options are configured under `channels_config`.
|
||||
|
||||
| Key | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `message_timeout_secs` | `300` | Base timeout in seconds for channel message processing; runtime scales this with tool-loop depth (up to 4x) |
|
||||
| `message_timeout_secs` | `300` | Base timeout in seconds for channel message processing; runtime scales this with tool-loop depth (up to 4x, overridable via `[pacing].message_timeout_scale_max`) |
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -612,7 +646,7 @@ Examples:
|
||||
Notes:
|
||||
|
||||
- Default `300s` is optimized for on-device LLMs (Ollama) which are slower than cloud APIs.
|
||||
- Runtime timeout budget is `message_timeout_secs * scale`, where `scale = min(max_tool_iterations, 4)` and a minimum of `1`.
|
||||
- Runtime timeout budget is `message_timeout_secs * scale`, where `scale = min(max_tool_iterations, cap)` and a minimum of `1`. The default cap is `4`; override with `[pacing].message_timeout_scale_max`.
|
||||
- This scaling avoids false timeouts when the first LLM turn is slow/retried but later tool-loop turns still need to complete.
|
||||
- If using cloud APIs (OpenAI, Anthropic, etc.), you can reduce this to `60` or lower.
|
||||
- Values below `30` are clamped to `30` to avoid immediate timeout churn.
|
||||
|
||||
+117
-6
@@ -2331,6 +2331,7 @@ pub(crate) async fn agent_turn(
|
||||
dedup_exempt_tools,
|
||||
activated_tools,
|
||||
model_switch_callback,
|
||||
&crate::config::PacingConfig::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2640,6 +2641,7 @@ pub(crate) async fn run_tool_call_loop(
|
||||
dedup_exempt_tools: &[String],
|
||||
activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
|
||||
model_switch_callback: Option<ModelSwitchCallback>,
|
||||
pacing: &crate::config::PacingConfig,
|
||||
) -> Result<String> {
|
||||
let max_iterations = if max_tool_iterations == 0 {
|
||||
DEFAULT_MAX_TOOL_ITERATIONS
|
||||
@@ -2648,6 +2650,14 @@ pub(crate) async fn run_tool_call_loop(
|
||||
};
|
||||
|
||||
let turn_id = Uuid::new_v4().to_string();
|
||||
let loop_started_at = Instant::now();
|
||||
let loop_ignore_tools: HashSet<&str> = pacing
|
||||
.loop_ignore_tools
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
let mut consecutive_identical_outputs: usize = 0;
|
||||
let mut last_tool_output_hash: Option<u64> = None;
|
||||
|
||||
for iteration in 0..max_iterations {
|
||||
let mut seen_tool_signatures: HashSet<(String, String)> = HashSet::new();
|
||||
@@ -2777,13 +2787,43 @@ pub(crate) async fn run_tool_call_loop(
|
||||
temperature,
|
||||
);
|
||||
|
||||
let chat_result = if let Some(token) = cancellation_token.as_ref() {
|
||||
tokio::select! {
|
||||
() = token.cancelled() => return Err(ToolLoopCancelled.into()),
|
||||
result = chat_future => result,
|
||||
// Wrap the LLM call with an optional per-step timeout from pacing config.
|
||||
// This catches a truly hung model response without terminating the overall
|
||||
// task loop (the per-message budget handles that separately).
|
||||
let chat_result = match pacing.step_timeout_secs {
|
||||
Some(step_secs) if step_secs > 0 => {
|
||||
let step_timeout = Duration::from_secs(step_secs);
|
||||
if let Some(token) = cancellation_token.as_ref() {
|
||||
tokio::select! {
|
||||
() = token.cancelled() => return Err(ToolLoopCancelled.into()),
|
||||
result = tokio::time::timeout(step_timeout, chat_future) => {
|
||||
match result {
|
||||
Ok(inner) => inner,
|
||||
Err(_) => anyhow::bail!(
|
||||
"LLM inference step timed out after {step_secs}s (step_timeout_secs)"
|
||||
),
|
||||
}
|
||||
},
|
||||
}
|
||||
} else {
|
||||
match tokio::time::timeout(step_timeout, chat_future).await {
|
||||
Ok(inner) => inner,
|
||||
Err(_) => anyhow::bail!(
|
||||
"LLM inference step timed out after {step_secs}s (step_timeout_secs)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(token) = cancellation_token.as_ref() {
|
||||
tokio::select! {
|
||||
() = token.cancelled() => return Err(ToolLoopCancelled.into()),
|
||||
result = chat_future => result,
|
||||
}
|
||||
} else {
|
||||
chat_future.await
|
||||
}
|
||||
}
|
||||
} else {
|
||||
chat_future.await
|
||||
};
|
||||
|
||||
let (response_text, parsed_text, tool_calls, assistant_history_content, native_tool_calls) =
|
||||
@@ -3282,7 +3322,13 @@ pub(crate) async fn run_tool_call_loop(
|
||||
ordered_results[*idx] = Some((call.name.clone(), call.tool_call_id.clone(), outcome));
|
||||
}
|
||||
|
||||
// Collect tool results and build per-tool output for loop detection.
|
||||
// Only non-ignored tool outputs contribute to the identical-output hash.
|
||||
let mut detection_relevant_output = String::new();
|
||||
for (tool_name, tool_call_id, outcome) in ordered_results.into_iter().flatten() {
|
||||
if !loop_ignore_tools.contains(tool_name.as_str()) {
|
||||
detection_relevant_output.push_str(&outcome.output);
|
||||
}
|
||||
individual_results.push((tool_call_id, outcome.output.clone()));
|
||||
let _ = writeln!(
|
||||
tool_results,
|
||||
@@ -3291,6 +3337,53 @@ pub(crate) async fn run_tool_call_loop(
|
||||
);
|
||||
}
|
||||
|
||||
// ── Time-gated loop detection ──────────────────────────
|
||||
// When pacing.loop_detection_min_elapsed_secs is set, identical-output
|
||||
// loop detection activates after the task has been running that long.
|
||||
// This avoids false-positive aborts on long-running browser/research
|
||||
// workflows while keeping aggressive protection for quick tasks.
|
||||
// When not configured, identical-output detection is disabled (preserving
|
||||
// existing behavior where only max_iterations prevents runaway loops).
|
||||
let loop_detection_active = match pacing.loop_detection_min_elapsed_secs {
|
||||
Some(min_secs) => loop_started_at.elapsed() >= Duration::from_secs(min_secs),
|
||||
None => false, // disabled when not configured (backwards compatible)
|
||||
};
|
||||
|
||||
if loop_detection_active && !detection_relevant_output.is_empty() {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
detection_relevant_output.hash(&mut hasher);
|
||||
let current_hash = hasher.finish();
|
||||
|
||||
if last_tool_output_hash == Some(current_hash) {
|
||||
consecutive_identical_outputs += 1;
|
||||
} else {
|
||||
consecutive_identical_outputs = 0;
|
||||
last_tool_output_hash = Some(current_hash);
|
||||
}
|
||||
|
||||
// Bail if we see 3+ consecutive identical tool outputs (clear runaway).
|
||||
if consecutive_identical_outputs >= 3 {
|
||||
runtime_trace::record_event(
|
||||
"tool_loop_identical_output_abort",
|
||||
Some(channel_name),
|
||||
Some(provider_name),
|
||||
Some(model),
|
||||
Some(&turn_id),
|
||||
Some(false),
|
||||
Some("identical tool output detected 3 consecutive times"),
|
||||
serde_json::json!({
|
||||
"iteration": iteration + 1,
|
||||
"consecutive_identical": consecutive_identical_outputs,
|
||||
}),
|
||||
);
|
||||
anyhow::bail!(
|
||||
"Agent loop aborted: identical tool output detected {} consecutive times",
|
||||
consecutive_identical_outputs
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add assistant message with tool calls + tool results to history.
|
||||
// Native mode: use JSON-structured messages so convert_messages() can
|
||||
// reconstruct proper OpenAI-format tool_calls and tool result messages.
|
||||
@@ -3840,6 +3933,7 @@ pub async fn run(
|
||||
&config.agent.tool_call_dedup_exempt,
|
||||
activated_handle.as_ref(),
|
||||
Some(model_switch_callback.clone()),
|
||||
&config.pacing,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -4067,6 +4161,7 @@ pub async fn run(
|
||||
&config.agent.tool_call_dedup_exempt,
|
||||
activated_handle.as_ref(),
|
||||
Some(model_switch_callback.clone()),
|
||||
&config.pacing,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -4964,6 +5059,7 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
&crate::config::PacingConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("provider without vision support should fail");
|
||||
@@ -5014,6 +5110,7 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
&crate::config::PacingConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("oversized payload must fail");
|
||||
@@ -5058,6 +5155,7 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
&crate::config::PacingConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect("valid multimodal payload should pass");
|
||||
@@ -5188,6 +5286,7 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
&crate::config::PacingConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect("parallel execution should complete");
|
||||
@@ -5258,6 +5357,7 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
&crate::config::PacingConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect("cron_add delivery defaults should be injected");
|
||||
@@ -5320,6 +5420,7 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
&crate::config::PacingConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect("explicit delivery mode should be preserved");
|
||||
@@ -5377,6 +5478,7 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
&crate::config::PacingConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect("loop should finish after deduplicating repeated calls");
|
||||
@@ -5446,6 +5548,7 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
&crate::config::PacingConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect("non-interactive shell should succeed for low-risk command");
|
||||
@@ -5506,6 +5609,7 @@ mod tests {
|
||||
&exempt,
|
||||
None,
|
||||
None,
|
||||
&crate::config::PacingConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect("loop should finish with exempt tool executing twice");
|
||||
@@ -5586,6 +5690,7 @@ mod tests {
|
||||
&exempt,
|
||||
None,
|
||||
None,
|
||||
&crate::config::PacingConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect("loop should complete");
|
||||
@@ -5643,6 +5748,7 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
&crate::config::PacingConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect("native fallback id flow should complete");
|
||||
@@ -5724,6 +5830,7 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
&crate::config::PacingConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect("native tool-call text should be relayed through on_delta");
|
||||
@@ -7709,6 +7816,7 @@ Let me check the result."#;
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
&crate::config::PacingConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect("tool loop should complete");
|
||||
@@ -7856,6 +7964,7 @@ Let me check the result."#;
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
&crate::config::PacingConfig::default(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -7934,6 +8043,7 @@ Let me check the result."#;
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
&crate::config::PacingConfig::default(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -7988,6 +8098,7 @@ Let me check the result."#;
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
&crate::config::PacingConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect("should succeed without cost scope");
|
||||
|
||||
+99
-3
@@ -222,9 +222,21 @@ fn effective_channel_message_timeout_secs(configured: u64) -> u64 {
|
||||
fn channel_message_timeout_budget_secs(
|
||||
message_timeout_secs: u64,
|
||||
max_tool_iterations: usize,
|
||||
) -> u64 {
|
||||
channel_message_timeout_budget_secs_with_cap(
|
||||
message_timeout_secs,
|
||||
max_tool_iterations,
|
||||
CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP,
|
||||
)
|
||||
}
|
||||
|
||||
fn channel_message_timeout_budget_secs_with_cap(
|
||||
message_timeout_secs: u64,
|
||||
max_tool_iterations: usize,
|
||||
scale_cap: u64,
|
||||
) -> u64 {
|
||||
let iterations = max_tool_iterations.max(1) as u64;
|
||||
let scale = iterations.min(CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP);
|
||||
let scale = iterations.min(scale_cap);
|
||||
message_timeout_secs.saturating_mul(scale)
|
||||
}
|
||||
|
||||
@@ -362,6 +374,7 @@ struct ChannelRuntimeContext {
|
||||
approval_manager: Arc<ApprovalManager>,
|
||||
activated_tools: Option<std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
|
||||
cost_tracking: Option<ChannelCostTrackingState>,
|
||||
pacing: crate::config::PacingConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -2402,8 +2415,15 @@ async fn process_channel_message(
|
||||
}
|
||||
|
||||
let model_switch_callback = get_model_switch_state();
|
||||
let timeout_budget_secs =
|
||||
channel_message_timeout_budget_secs(ctx.message_timeout_secs, ctx.max_tool_iterations);
|
||||
let scale_cap = ctx
|
||||
.pacing
|
||||
.message_timeout_scale_max
|
||||
.unwrap_or(CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP);
|
||||
let timeout_budget_secs = channel_message_timeout_budget_secs_with_cap(
|
||||
ctx.message_timeout_secs,
|
||||
ctx.max_tool_iterations,
|
||||
scale_cap,
|
||||
);
|
||||
let cost_tracking_context = ctx.cost_tracking.clone().map(|state| {
|
||||
crate::agent::loop_::ToolLoopCostTrackingContext::new(state.tracker, state.prices)
|
||||
});
|
||||
@@ -2445,6 +2465,7 @@ async fn process_channel_message(
|
||||
ctx.tool_call_dedup_exempt.as_ref(),
|
||||
ctx.activated_tools.as_ref(),
|
||||
Some(model_switch_callback.clone()),
|
||||
&ctx.pacing,
|
||||
),
|
||||
),
|
||||
) => LlmExecutionResult::Completed(result),
|
||||
@@ -4641,6 +4662,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
tracker,
|
||||
prices: Arc::new(config.cost.prices.clone()),
|
||||
}),
|
||||
pacing: config.pacing.clone(),
|
||||
});
|
||||
|
||||
// Hydrate in-memory conversation histories from persisted JSONL session files.
|
||||
@@ -4737,6 +4759,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_message_timeout_budget_with_custom_scale_cap() {
|
||||
assert_eq!(
|
||||
channel_message_timeout_budget_secs_with_cap(300, 8, 8),
|
||||
300 * 8
|
||||
);
|
||||
assert_eq!(
|
||||
channel_message_timeout_budget_secs_with_cap(300, 20, 8),
|
||||
300 * 8
|
||||
);
|
||||
assert_eq!(
|
||||
channel_message_timeout_budget_secs_with_cap(300, 10, 1),
|
||||
300
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacing_config_defaults_preserve_existing_behavior() {
|
||||
let pacing = crate::config::PacingConfig::default();
|
||||
assert!(pacing.step_timeout_secs.is_none());
|
||||
assert!(pacing.loop_detection_min_elapsed_secs.is_none());
|
||||
assert!(pacing.loop_ignore_tools.is_empty());
|
||||
assert!(pacing.message_timeout_scale_max.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacing_message_timeout_scale_max_overrides_default_cap() {
|
||||
// Custom cap of 8 scales budget proportionally
|
||||
assert_eq!(
|
||||
channel_message_timeout_budget_secs_with_cap(300, 10, 8),
|
||||
300 * 8
|
||||
);
|
||||
// Default cap produces the standard behavior
|
||||
assert_eq!(
|
||||
channel_message_timeout_budget_secs_with_cap(
|
||||
300,
|
||||
10,
|
||||
CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP
|
||||
),
|
||||
300 * CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_window_overflow_error_detector_matches_known_messages() {
|
||||
let overflow_err = anyhow::anyhow!(
|
||||
@@ -4941,6 +5006,7 @@ mod tests {
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
};
|
||||
|
||||
assert!(compact_sender_history(&ctx, &sender));
|
||||
@@ -5057,6 +5123,7 @@ mod tests {
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
};
|
||||
|
||||
append_sender_turn(&ctx, &sender, ChatMessage::user("hello"));
|
||||
@@ -5129,6 +5196,7 @@ mod tests {
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
};
|
||||
|
||||
assert!(rollback_orphan_user_turn(&ctx, &sender, "pending"));
|
||||
@@ -5220,6 +5288,7 @@ mod tests {
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
};
|
||||
|
||||
assert!(rollback_orphan_user_turn(
|
||||
@@ -5761,6 +5830,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -5842,6 +5912,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -5937,6 +6008,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -6017,6 +6089,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -6107,6 +6180,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -6218,6 +6292,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -6310,6 +6385,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -6417,6 +6493,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -6509,6 +6586,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -6591,6 +6669,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -6788,6 +6867,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(4);
|
||||
@@ -6890,6 +6970,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
|
||||
@@ -7007,6 +7088,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
|
||||
@@ -7121,6 +7203,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
|
||||
@@ -7217,6 +7300,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -7297,6 +7381,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -8063,6 +8148,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -8194,6 +8280,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -8365,6 +8452,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -8473,6 +8561,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -9045,6 +9134,7 @@ This is an example JSON object for profile settings."#;
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
// Simulate a photo attachment message with [IMAGE:] marker.
|
||||
@@ -9132,6 +9222,7 @@ This is an example JSON object for profile settings."#;
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -9294,6 +9385,7 @@ This is an example JSON object for profile settings."#;
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -9405,6 +9497,7 @@ This is an example JSON object for profile settings."#;
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -9508,6 +9601,7 @@ This is an example JSON object for profile settings."#;
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -9631,6 +9725,7 @@ This is an example JSON object for profile settings."#;
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
@@ -9892,6 +9987,7 @@ This is an example JSON object for profile settings."#;
|
||||
)),
|
||||
activated_tools: None,
|
||||
cost_tracking: None,
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
});
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ pub use schema::{
|
||||
MatrixConfig, McpConfig, McpServerConfig, McpTransport, MemoryConfig, Microsoft365Config,
|
||||
ModelRouteConfig, MultimodalConfig, NextcloudTalkConfig, NodeTransportConfig, NodesConfig,
|
||||
NotionConfig, ObservabilityConfig, OpenAiSttConfig, OpenAiTtsConfig, OpenVpnTunnelConfig,
|
||||
OtpConfig, OtpMethod, PeripheralBoardConfig, PeripheralsConfig, PluginsConfig,
|
||||
OtpConfig, OtpMethod, PacingConfig, PeripheralBoardConfig, PeripheralsConfig, PluginsConfig,
|
||||
ProjectIntelConfig, ProxyConfig, ProxyScope, QdrantConfig, QueryClassificationConfig,
|
||||
ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig,
|
||||
SchedulerConfig, SecretsConfig, SecurityConfig, SecurityOpsConfig, SkillCreationConfig,
|
||||
|
||||
@@ -165,6 +165,10 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub agent: AgentConfig,
|
||||
|
||||
/// Pacing controls for slow/local LLM workloads (`[pacing]`).
|
||||
#[serde(default)]
|
||||
pub pacing: PacingConfig,
|
||||
|
||||
/// Skills loading and community repository behavior (`[skills]`).
|
||||
#[serde(default)]
|
||||
pub skills: SkillsConfig,
|
||||
@@ -1277,6 +1281,43 @@ impl Default for AgentConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pacing ────────────────────────────────────────────────────────
|
||||
|
||||
/// Pacing controls for slow/local LLM workloads (`[pacing]` section).
|
||||
///
|
||||
/// All fields are optional and default to values that preserve existing
|
||||
/// behavior. When set, they extend — not replace — the existing timeout
|
||||
/// and loop-detection subsystems.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PacingConfig {
|
||||
/// Per-step timeout in seconds: the maximum time allowed for a single
|
||||
/// LLM inference turn, independent of the total message budget.
|
||||
/// `None` means no per-step timeout (existing behavior).
|
||||
#[serde(default)]
|
||||
pub step_timeout_secs: Option<u64>,
|
||||
|
||||
/// Minimum elapsed seconds before loop detection activates.
|
||||
/// Tasks completing under this threshold get aggressive loop protection;
|
||||
/// longer-running tasks receive a grace period before the detector starts
|
||||
/// counting. `None` means loop detection is always active (existing behavior).
|
||||
#[serde(default)]
|
||||
pub loop_detection_min_elapsed_secs: Option<u64>,
|
||||
|
||||
/// Tool names excluded from identical-output / alternating-pattern loop
|
||||
/// detection. Useful for browser workflows where `browser_screenshot`
|
||||
/// structurally resembles a loop even when making progress.
|
||||
#[serde(default)]
|
||||
pub loop_ignore_tools: Vec<String>,
|
||||
|
||||
/// Override for the hardcoded timeout scaling cap (default: 4).
|
||||
/// The channel message timeout budget is computed as:
|
||||
/// `message_timeout_secs * min(max_tool_iterations, message_timeout_scale_max)`
|
||||
/// Raising this value lets long multi-step tasks with slow local models
|
||||
/// receive a proportionally larger budget without inflating the base timeout.
|
||||
#[serde(default)]
|
||||
pub message_timeout_scale_max: Option<u64>,
|
||||
}
|
||||
|
||||
/// Skills loading configuration (`[skills]` section).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -6727,6 +6768,7 @@ impl Default for Config {
|
||||
reliability: ReliabilityConfig::default(),
|
||||
scheduler: SchedulerConfig::default(),
|
||||
agent: AgentConfig::default(),
|
||||
pacing: PacingConfig::default(),
|
||||
skills: SkillsConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
embedding_routes: Vec::new(),
|
||||
@@ -9673,6 +9715,7 @@ default_temperature = 0.7
|
||||
google_workspace: GoogleWorkspaceConfig::default(),
|
||||
proxy: ProxyConfig::default(),
|
||||
agent: AgentConfig::default(),
|
||||
pacing: PacingConfig::default(),
|
||||
identity: IdentityConfig::default(),
|
||||
cost: CostConfig::default(),
|
||||
peripherals: PeripheralsConfig::default(),
|
||||
@@ -9944,6 +9987,47 @@ tool_dispatcher = "xml"
|
||||
assert_eq!(parsed.agent.tool_dispatcher, "xml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn pacing_config_defaults_are_all_none_or_empty() {
|
||||
let cfg = PacingConfig::default();
|
||||
assert!(cfg.step_timeout_secs.is_none());
|
||||
assert!(cfg.loop_detection_min_elapsed_secs.is_none());
|
||||
assert!(cfg.loop_ignore_tools.is_empty());
|
||||
assert!(cfg.message_timeout_scale_max.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn pacing_config_deserializes_from_toml() {
|
||||
let raw = r#"
|
||||
default_temperature = 0.7
|
||||
[pacing]
|
||||
step_timeout_secs = 120
|
||||
loop_detection_min_elapsed_secs = 60
|
||||
loop_ignore_tools = ["browser_screenshot", "browser_navigate"]
|
||||
message_timeout_scale_max = 8
|
||||
"#;
|
||||
let parsed: Config = toml::from_str(raw).unwrap();
|
||||
assert_eq!(parsed.pacing.step_timeout_secs, Some(120));
|
||||
assert_eq!(parsed.pacing.loop_detection_min_elapsed_secs, Some(60));
|
||||
assert_eq!(
|
||||
parsed.pacing.loop_ignore_tools,
|
||||
vec!["browser_screenshot", "browser_navigate"]
|
||||
);
|
||||
assert_eq!(parsed.pacing.message_timeout_scale_max, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn pacing_config_absent_preserves_defaults() {
|
||||
let raw = r#"
|
||||
default_temperature = 0.7
|
||||
"#;
|
||||
let parsed: Config = toml::from_str(raw).unwrap();
|
||||
assert!(parsed.pacing.step_timeout_secs.is_none());
|
||||
assert!(parsed.pacing.loop_detection_min_elapsed_secs.is_none());
|
||||
assert!(parsed.pacing.loop_ignore_tools.is_empty());
|
||||
assert!(parsed.pacing.message_timeout_scale_max.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_directory_handles_existing_directory() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
@@ -10012,6 +10096,7 @@ tool_dispatcher = "xml"
|
||||
google_workspace: GoogleWorkspaceConfig::default(),
|
||||
proxy: ProxyConfig::default(),
|
||||
agent: AgentConfig::default(),
|
||||
pacing: PacingConfig::default(),
|
||||
identity: IdentityConfig::default(),
|
||||
cost: CostConfig::default(),
|
||||
peripherals: PeripheralsConfig::default(),
|
||||
|
||||
@@ -1438,6 +1438,7 @@ mod tests {
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
path_prefix: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+55
-22
@@ -348,6 +348,8 @@ pub struct AppState {
|
||||
pub shutdown_tx: tokio::sync::watch::Sender<bool>,
|
||||
/// Registry of dynamically connected nodes
|
||||
pub node_registry: Arc<nodes::NodeRegistry>,
|
||||
/// Path prefix for reverse-proxy deployments (empty string = no prefix)
|
||||
pub path_prefix: String,
|
||||
/// Session backend for persisting gateway WS chat sessions
|
||||
pub session_backend: Option<Arc<dyn SessionBackend>>,
|
||||
/// Device registry for paired device management
|
||||
@@ -673,6 +675,13 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
idempotency_max_keys,
|
||||
));
|
||||
|
||||
// Resolve optional path prefix for reverse-proxy deployments.
|
||||
let path_prefix: Option<&str> = config
|
||||
.gateway
|
||||
.path_prefix
|
||||
.as_deref()
|
||||
.filter(|p| !p.is_empty());
|
||||
|
||||
// ── Tunnel ────────────────────────────────────────────────
|
||||
let tunnel = crate::tunnel::create_tunnel(&config.tunnel)?;
|
||||
let mut tunnel_url: Option<String> = None;
|
||||
@@ -691,18 +700,19 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
println!("🦀 ZeroClaw Gateway listening on http://{display_addr}");
|
||||
let pfx = path_prefix.unwrap_or("");
|
||||
println!("🦀 ZeroClaw Gateway listening on http://{display_addr}{pfx}");
|
||||
if let Some(ref url) = tunnel_url {
|
||||
println!(" 🌐 Public URL: {url}");
|
||||
}
|
||||
println!(" 🌐 Web Dashboard: http://{display_addr}/");
|
||||
println!(" 🌐 Web Dashboard: http://{display_addr}{pfx}/");
|
||||
if let Some(code) = pairing.pairing_code() {
|
||||
println!();
|
||||
println!(" 🔐 PAIRING REQUIRED — use this one-time code:");
|
||||
println!(" ┌──────────────┐");
|
||||
println!(" │ {code} │");
|
||||
println!(" └──────────────┘");
|
||||
println!();
|
||||
println!(" Send: POST {pfx}/pair with header X-Pairing-Code: {code}");
|
||||
} else if pairing.require_pairing() {
|
||||
println!(" 🔒 Pairing: ACTIVE (bearer token required)");
|
||||
println!(" To pair a new device: zeroclaw gateway get-paircode --new");
|
||||
@@ -711,29 +721,29 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
println!(" ⚠️ Pairing: DISABLED (all requests accepted)");
|
||||
println!();
|
||||
}
|
||||
println!(" POST /pair — pair a new client (X-Pairing-Code header)");
|
||||
println!(" POST /webhook — {{\"message\": \"your prompt\"}}");
|
||||
println!(" POST {pfx}/pair — pair a new client (X-Pairing-Code header)");
|
||||
println!(" POST {pfx}/webhook — {{\"message\": \"your prompt\"}}");
|
||||
if whatsapp_channel.is_some() {
|
||||
println!(" GET /whatsapp — Meta webhook verification");
|
||||
println!(" POST /whatsapp — WhatsApp message webhook");
|
||||
println!(" GET {pfx}/whatsapp — Meta webhook verification");
|
||||
println!(" POST {pfx}/whatsapp — WhatsApp message webhook");
|
||||
}
|
||||
if linq_channel.is_some() {
|
||||
println!(" POST /linq — Linq message webhook (iMessage/RCS/SMS)");
|
||||
println!(" POST {pfx}/linq — Linq message webhook (iMessage/RCS/SMS)");
|
||||
}
|
||||
if wati_channel.is_some() {
|
||||
println!(" GET /wati — WATI webhook verification");
|
||||
println!(" POST /wati — WATI message webhook");
|
||||
println!(" GET {pfx}/wati — WATI webhook verification");
|
||||
println!(" POST {pfx}/wati — WATI message webhook");
|
||||
}
|
||||
if nextcloud_talk_channel.is_some() {
|
||||
println!(" POST /nextcloud-talk — Nextcloud Talk bot webhook");
|
||||
println!(" POST {pfx}/nextcloud-talk — Nextcloud Talk bot webhook");
|
||||
}
|
||||
println!(" GET /api/* — REST API (bearer token required)");
|
||||
println!(" GET /ws/chat — WebSocket agent chat");
|
||||
println!(" GET {pfx}/api/* — REST API (bearer token required)");
|
||||
println!(" GET {pfx}/ws/chat — WebSocket agent chat");
|
||||
if config.nodes.enabled {
|
||||
println!(" GET /ws/nodes — WebSocket node discovery");
|
||||
println!(" GET {pfx}/ws/nodes — WebSocket node discovery");
|
||||
}
|
||||
println!(" GET /health — health check");
|
||||
println!(" GET /metrics — Prometheus metrics");
|
||||
println!(" GET {pfx}/health — health check");
|
||||
println!(" GET {pfx}/metrics — Prometheus metrics");
|
||||
println!(" Press Ctrl+C to stop.\n");
|
||||
|
||||
crate::health::mark_component_ok("gateway");
|
||||
@@ -799,6 +809,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
session_backend,
|
||||
device_registry,
|
||||
pending_pairings,
|
||||
path_prefix: path_prefix.unwrap_or("").to_string(),
|
||||
};
|
||||
|
||||
// Config PUT needs larger body limit (1MB)
|
||||
@@ -807,7 +818,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
.layer(RequestBodyLimitLayer::new(1_048_576));
|
||||
|
||||
// Build router with middleware
|
||||
let app = Router::new()
|
||||
let inner = Router::new()
|
||||
// ── Admin routes (for CLI management) ──
|
||||
.route("/admin/shutdown", post(handle_admin_shutdown))
|
||||
.route("/admin/paircode", get(handle_admin_paircode))
|
||||
@@ -867,12 +878,12 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
|
||||
// ── Plugin management API (requires plugins-wasm feature) ──
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
let app = app.route(
|
||||
let inner = inner.route(
|
||||
"/api/plugins",
|
||||
get(api_plugins::plugin_routes::list_plugins),
|
||||
);
|
||||
|
||||
let app = app
|
||||
let inner = inner
|
||||
// ── SSE event stream ──
|
||||
.route("/api/events", get(sse::handle_sse_events))
|
||||
// ── WebSocket agent chat ──
|
||||
@@ -883,14 +894,27 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
.route("/_app/{*path}", get(static_files::handle_static))
|
||||
// ── Config PUT with larger body limit ──
|
||||
.merge(config_put_router)
|
||||
// ── SPA fallback: non-API GET requests serve index.html ──
|
||||
.fallback(get(static_files::handle_spa_fallback))
|
||||
.with_state(state)
|
||||
.layer(RequestBodyLimitLayer::new(MAX_BODY_SIZE))
|
||||
.layer(TimeoutLayer::with_status_code(
|
||||
StatusCode::REQUEST_TIMEOUT,
|
||||
Duration::from_secs(gateway_request_timeout_secs()),
|
||||
))
|
||||
// ── SPA fallback: non-API GET requests serve index.html ──
|
||||
.fallback(get(static_files::handle_spa_fallback));
|
||||
));
|
||||
|
||||
// Nest under path prefix when configured (axum strips prefix before routing).
|
||||
// nest() at "/prefix" handles both "/prefix" and "/prefix/*" but not "/prefix/"
|
||||
// with a trailing slash, so we add a fallback redirect for that case.
|
||||
let app = if let Some(prefix) = path_prefix {
|
||||
let redirect_target = prefix.to_string();
|
||||
Router::new().nest(prefix, inner).route(
|
||||
&format!("{prefix}/"),
|
||||
get(|| async move { axum::response::Redirect::permanent(&redirect_target) }),
|
||||
)
|
||||
} else {
|
||||
inner
|
||||
};
|
||||
|
||||
// Run the server with graceful shutdown
|
||||
axum::serve(
|
||||
@@ -1982,6 +2006,7 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
path_prefix: String::new(),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
@@ -2037,6 +2062,7 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
path_prefix: String::new(),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
@@ -2421,6 +2447,7 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
path_prefix: String::new(),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
@@ -2490,6 +2517,7 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
path_prefix: String::new(),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
@@ -2571,6 +2599,7 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
path_prefix: String::new(),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
@@ -2624,6 +2653,7 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
path_prefix: String::new(),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
@@ -2682,6 +2712,7 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
path_prefix: String::new(),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
@@ -2745,6 +2776,7 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
path_prefix: String::new(),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
@@ -2804,6 +2836,7 @@ mod tests {
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
node_registry: Arc::new(nodes::NodeRegistry::new(16)),
|
||||
path_prefix: String::new(),
|
||||
session_backend: None,
|
||||
device_registry: None,
|
||||
pending_pairings: None,
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
//! Uses `rust-embed` to bundle the `web/dist/` directory into the binary at compile time.
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header, StatusCode, Uri},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use rust_embed::Embed;
|
||||
|
||||
use super::AppState;
|
||||
|
||||
#[derive(Embed)]
|
||||
#[folder = "web/dist/"]
|
||||
struct WebAssets;
|
||||
@@ -23,16 +26,41 @@ pub async fn handle_static(uri: Uri) -> Response {
|
||||
serve_embedded_file(path)
|
||||
}
|
||||
|
||||
/// SPA fallback: serve index.html for any non-API, non-static GET request
|
||||
pub async fn handle_spa_fallback() -> Response {
|
||||
if WebAssets::get("index.html").is_none() {
|
||||
/// SPA fallback: serve index.html for any non-API, non-static GET request.
|
||||
/// Injects `window.__ZEROCLAW_BASE__` so the frontend knows the path prefix.
|
||||
pub async fn handle_spa_fallback(State(state): State<AppState>) -> Response {
|
||||
let Some(content) = WebAssets::get("index.html") else {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Web dashboard not available. Build it with: cd web && npm ci && npm run build",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
serve_embedded_file("index.html")
|
||||
};
|
||||
|
||||
let html = String::from_utf8_lossy(&content.data);
|
||||
|
||||
// Inject path prefix for the SPA and rewrite asset paths in the HTML
|
||||
let html = if state.path_prefix.is_empty() {
|
||||
html.into_owned()
|
||||
} else {
|
||||
let pfx = &state.path_prefix;
|
||||
// JSON-encode the prefix to safely embed in a <script> block
|
||||
let json_pfx = serde_json::to_string(pfx).unwrap_or_else(|_| "\"\"".to_string());
|
||||
let script = format!("<script>window.__ZEROCLAW_BASE__={json_pfx};</script>");
|
||||
// Rewrite absolute /_app/ references so the browser requests {prefix}/_app/...
|
||||
html.replace("/_app/", &format!("{pfx}/_app/"))
|
||||
.replace("<head>", &format!("<head>{script}"))
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
|
||||
(header::CACHE_CONTROL, "no-cache".to_string()),
|
||||
],
|
||||
html,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn serve_embedded_file(path: &str) -> Response {
|
||||
|
||||
@@ -154,6 +154,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
|
||||
reliability: crate::config::ReliabilityConfig::default(),
|
||||
scheduler: crate::config::schema::SchedulerConfig::default(),
|
||||
agent: crate::config::schema::AgentConfig::default(),
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
skills: crate::config::SkillsConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
embedding_routes: Vec::new(),
|
||||
@@ -576,6 +577,7 @@ async fn run_quick_setup_with_home(
|
||||
reliability: crate::config::ReliabilityConfig::default(),
|
||||
scheduler: crate::config::schema::SchedulerConfig::default(),
|
||||
agent: crate::config::schema::AgentConfig::default(),
|
||||
pacing: crate::config::PacingConfig::default(),
|
||||
skills: crate::config::SkillsConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
embedding_routes: Vec::new(),
|
||||
|
||||
@@ -530,6 +530,7 @@ impl DelegateTool {
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
&crate::config::PacingConfig::default(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -100,6 +100,10 @@ fn gateway_config_defaults_are_secure() {
|
||||
!gw.trust_forwarded_headers,
|
||||
"forwarded headers should be untrusted by default"
|
||||
);
|
||||
assert!(
|
||||
gw.path_prefix.is_none(),
|
||||
"path_prefix should default to None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -124,6 +128,7 @@ fn gateway_config_toml_roundtrip() {
|
||||
host: "0.0.0.0".into(),
|
||||
require_pairing: false,
|
||||
pair_rate_limit_per_minute: 5,
|
||||
path_prefix: Some("/zeroclaw".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -134,6 +139,7 @@ fn gateway_config_toml_roundtrip() {
|
||||
assert_eq!(parsed.host, "0.0.0.0");
|
||||
assert!(!parsed.require_pairing);
|
||||
assert_eq!(parsed.pair_rate_limit_per_minute, 5);
|
||||
assert_eq!(parsed.path_prefix.as_deref(), Some("/zeroclaw"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -163,6 +169,93 @@ port = 9090
|
||||
assert_eq!(parsed.gateway.pair_rate_limit_per_minute, 10);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GatewayConfig path_prefix validation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn gateway_path_prefix_rejects_missing_leading_slash() {
|
||||
let mut config = Config::default();
|
||||
config.gateway.path_prefix = Some("zeroclaw".into());
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("must start with '/'"),
|
||||
"expected leading-slash error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_path_prefix_rejects_trailing_slash() {
|
||||
let mut config = Config::default();
|
||||
config.gateway.path_prefix = Some("/zeroclaw/".into());
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("must not end with '/'"),
|
||||
"expected trailing-slash error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_path_prefix_rejects_bare_slash() {
|
||||
let mut config = Config::default();
|
||||
config.gateway.path_prefix = Some("/".into());
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("must not end with '/'"),
|
||||
"expected bare-slash error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_path_prefix_accepts_valid_prefixes() {
|
||||
for prefix in ["/zeroclaw", "/apps/zeroclaw", "/api/hassio_ingress/abc123"] {
|
||||
let mut config = Config::default();
|
||||
config.gateway.path_prefix = Some(prefix.into());
|
||||
config
|
||||
.validate()
|
||||
.unwrap_or_else(|e| panic!("prefix {prefix:?} should be valid, got: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_path_prefix_rejects_unsafe_characters() {
|
||||
for prefix in [
|
||||
"/zero claw",
|
||||
"/zero<claw",
|
||||
"/zero>claw",
|
||||
"/zero\"claw",
|
||||
"/zero?query",
|
||||
"/zero#frag",
|
||||
] {
|
||||
let mut config = Config::default();
|
||||
config.gateway.path_prefix = Some(prefix.into());
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("invalid character"),
|
||||
"prefix {prefix:?} should be rejected, got: {err}"
|
||||
);
|
||||
}
|
||||
// Leading/trailing whitespace is rejected by the starts_with('/') or
|
||||
// invalid-character check — either way it must not pass validation.
|
||||
for prefix in [" /zeroclaw ", " /zeroclaw"] {
|
||||
let mut config = Config::default();
|
||||
config.gateway.path_prefix = Some(prefix.into());
|
||||
assert!(
|
||||
config.validate().is_err(),
|
||||
"whitespace-padded prefix {prefix:?} should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_path_prefix_accepts_none() {
|
||||
let config = Config::default();
|
||||
assert!(config.gateway.path_prefix.is_none());
|
||||
config
|
||||
.validate()
|
||||
.expect("absent path_prefix should be valid");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SecurityConfig boundary tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
+2
-1
@@ -16,6 +16,7 @@ import Pairing from './pages/Pairing';
|
||||
import { AuthProvider, useAuth } from './hooks/useAuth';
|
||||
import { DraftContext, useDraftStore } from './hooks/useDraft';
|
||||
import { setLocale, type Locale } from './lib/i18n';
|
||||
import { basePath } from './lib/basePath';
|
||||
import { getAdminPairCode } from './lib/api';
|
||||
|
||||
// Locale context
|
||||
@@ -131,7 +132,7 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<img
|
||||
src="/_app/zeroclaw-trans.png"
|
||||
src={`${basePath}/_app/zeroclaw-trans.png`}
|
||||
alt="ZeroClaw"
|
||||
className="h-20 w-20 rounded-2xl object-cover mx-auto mb-4 animate-float"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { basePath } from '../../lib/basePath';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
MessageSquare,
|
||||
@@ -34,7 +35,7 @@ export default function Sidebar() {
|
||||
<div className="relative shrink-0">
|
||||
<div className="absolute -inset-1.5 rounded-xl" style={{ background: 'linear-gradient(135deg, rgba(var(--pc-accent-rgb), 0.15), rgba(var(--pc-accent-rgb), 0.05))' }} />
|
||||
<img
|
||||
src="/_app/zeroclaw-trans.png"
|
||||
src={`${basePath}/_app/zeroclaw-trans.png`}
|
||||
alt="ZeroClaw"
|
||||
className="relative h-9 w-9 rounded-xl object-cover"
|
||||
onError={(e) => {
|
||||
|
||||
+4
-3
@@ -11,6 +11,7 @@ import type {
|
||||
HealthSnapshot,
|
||||
} from '../types/api';
|
||||
import { clearToken, getToken, setToken } from './auth';
|
||||
import { basePath } from './basePath';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base fetch wrapper
|
||||
@@ -42,7 +43,7 @@ export async function apiFetch<T = unknown>(
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const response = await fetch(path, { ...options, headers });
|
||||
const response = await fetch(`${basePath}${path}`, { ...options, headers });
|
||||
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
@@ -78,7 +79,7 @@ function unwrapField<T>(value: T | Record<string, T>, key: string): T {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function pair(code: string): Promise<{ token: string }> {
|
||||
const response = await fetch('/pair', {
|
||||
const response = await fetch(`${basePath}/pair`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Pairing-Code': code },
|
||||
});
|
||||
@@ -106,7 +107,7 @@ export async function getAdminPairCode(): Promise<{ pairing_code: string | null;
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getPublicHealth(): Promise<{ require_pairing: boolean; paired: boolean }> {
|
||||
const response = await fetch('/health');
|
||||
const response = await fetch(`${basePath}/health`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Health check failed (${response.status})`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Runtime base path injected by the Rust gateway into index.html.
|
||||
// Allows the SPA to work under a reverse-proxy path prefix.
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__ZEROCLAW_BASE__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/** Gateway path prefix (e.g. "/zeroclaw"), or empty string when served at root. */
|
||||
export const basePath: string = (window.__ZEROCLAW_BASE__ ?? '').replace(/\/+$/, '');
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import type { SSEEvent } from '../types/api';
|
||||
import { getToken } from './auth';
|
||||
import { basePath } from './basePath';
|
||||
|
||||
export type SSEEventHandler = (event: SSEEvent) => void;
|
||||
export type SSEErrorHandler = (error: Event | Error) => void;
|
||||
@@ -41,7 +42,7 @@ export class SSEClient {
|
||||
private readonly autoReconnect: boolean;
|
||||
|
||||
constructor(options: SSEClientOptions = {}) {
|
||||
this.path = options.path ?? '/api/events';
|
||||
this.path = options.path ?? `${basePath}/api/events`;
|
||||
this.reconnectDelay = options.reconnectDelay ?? DEFAULT_RECONNECT_DELAY;
|
||||
this.maxReconnectDelay = options.maxReconnectDelay ?? MAX_RECONNECT_DELAY;
|
||||
this.autoReconnect = options.autoReconnect ?? true;
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import type { WsMessage } from '../types/api';
|
||||
import { getToken } from './auth';
|
||||
import { basePath } from './basePath';
|
||||
import { generateUUID } from './uuid';
|
||||
|
||||
export type WsMessageHandler = (msg: WsMessage) => void;
|
||||
@@ -69,7 +70,7 @@ export class WebSocketClient {
|
||||
const params = new URLSearchParams();
|
||||
if (token) params.set('token', token);
|
||||
params.set('session_id', sessionId);
|
||||
const url = `${this.baseUrl}/ws/chat?${params.toString()}`;
|
||||
const url = `${this.baseUrl}${basePath}/ws/chat?${params.toString()}`;
|
||||
|
||||
const protocols: string[] = ['zeroclaw.v1'];
|
||||
if (token) protocols.push(`bearer.${token}`);
|
||||
|
||||
+3
-2
@@ -2,12 +2,13 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import { basePath } from './lib/basePath';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
{/* Vite base '/_app/' scopes static asset URLs only; app routes stay rooted at '/' for SPA fallback. */}
|
||||
<BrowserRouter basename="/">
|
||||
{/* basePath is injected by the Rust gateway at serve time for reverse-proxy prefix support. */}
|
||||
<BrowserRouter basename={basePath || '/'}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
|
||||
Reference in New Issue
Block a user