mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(ux): complete UX overhaul — design system, boot screen, onboarding, web polish Shared design system: CSS custom properties for spacing, typography, transitions, and color tokens used across web UI and boot screen. Boot screen: compact feature-tags line showing enabled subsystems (db, tools, routines, heartbeat, skills, sandbox, embeddings) at a glance. Downgrade startup info logs (libSQL, webhook, workspace seed) to debug level since the boot screen now covers this. Onboarding wizard: model picker with live API fetch, provider-aware auth flow, improved error recovery and progress display. Web UI: ARIA attributes, welcome card, streaming debounce, connection status banner, skeleton loaders, send cooldown. CLI: doctor command enhancements, status command cleanup, REPL banner consolidation, shared fmt module. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(ux): Apple-level design refinements — spring physics, glass morphism, chat polish Merge staging theme support (dark/light/system toggle) and layer UX polish on top: spring-physics motion, glass morphism depth, chat experience improvements, and responsive mobile refinements. Design system: - Restore and extend design token system (spacing, typography, timing, easing) with legacy aliases for theme compatibility - Add shadow tiers, accent glow, glass morphism, spring easing tokens - Tokens defined in both dark (:root) and light ([data-theme="light"]) Micro-interactions (Phase 2): - Spring-overshoot message entry animation (slideUp) - Spring-scale button press on all interactive buttons - Tab crossfade animation, tool card smooth accordion (max-height) - Modal scale(0.95) + blur(8px) entry, toast spring slide - Sidebar width crossfade, card hover lift Visual depth (Phase 3): - Tab bar glass morphism + surface highlight + sliding indicator - Active tab accent background pill - Assistant message accent left border, user message bubble tail - Floating input area (rounded + shadow + margin) Chat polish (Phase 4): - Smooth streaming cursor (cursorPulse), message hover timestamps - Time separators (Today/Yesterday/date) - Textarea smooth auto-expand, send button glow Settings & forms (Phase 5): - iOS-style toggle switches for boolean settings - Input focus glow, save feedback spring animation - Welcome card with gradient background + proper spacing - Sticky settings group headers with glass backdrop Accessibility & mobile (Phase 6): - Animated focus ring, prefers-reduced-motion global kill-switch - Touch target audit (44px min), mobile bottom-sheet modals - Mobile bottom tab bar, toast redesign (icon + border + countdown) - Thread hover translateX, badge in_progress pulse Bug fixes: - Gateway/TEE popover z-index (tab-bar z-index: 200, popovers 500) - Connection lost banner as fixed top bar instead of flex child - Sidebar collapse keeps toggle + new thread buttons visible - Downgrade noisy startup logs (db, webhook, vector) to debug - Remove green dot pulse animation on connected status - Deduplicate confirm-modal in HTML, add tab-indicator div Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(web): mobile layout improvements — sidebar toggle, settings drill-down, tab bar polish - Fix mobile sidebar toggle: use expanded-mobile class instead of collapsed, add backdrop overlay, auto-close on thread select, outside-click dismiss - Settings: replace cramped horizontal tabs with drill-down navigation (category list → detail view → back button) - Bottom tab bar: add glass morphism, hide theme toggle, flip tab indicator to top edge - Keep thread toggle button visible in collapsed 36px sidebar strip Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(repl): interactive approval selector and transient status lines - Replace ASCII-art approval box with clean horizontal rule card - Add inquire-based interactive selector for tool approvals (↑↓ + Enter) - Selector runs directly from send_status via spawn_blocking, with stdin_locked flag to prevent readline from competing for stdin - Transient thinking/tool-started lines: each replaces the previous, all erased before final output (no clutter left in scrollback) - Esc in selector sends denial so agent never gets stuck Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: widen TurnCost token fields to u64 and remove unused variable - Change input_tokens/output_tokens from u32 to u64 in StatusUpdate::TurnCost, SseEvent::TurnCost, and the thread_ops emit site to avoid truncation on large conversations - Remove unused _routine_engine_for_loop binding in agent_loop.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: reduce startup log noise — demote info to debug Demote routine startup messages (builder, WASM tools, tunnel, WASM channels) from info to debug so the default log output stays clean. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(web): allow CDN scripts in CSP connect-src directive Add cdn.jsdelivr.net and cdnjs.cloudflare.com to connect-src so the browser can fetch marked.js and DOMPurify without CSP violations. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: fix cargo fmt in repl.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(web): gate turn_cost SSE handler on current thread Prevents cost badge from attaching to the wrong message when switching threads or receiving events from background threads. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * ci: retrigger CI * fix: add missing extension_manager to webhook EngineContext The webhook trigger path added in #736 was missing the extension_manager field introduced by #1453. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: ignore RUSTSEC-2026-0049 rustls-webpki CRL advisory Low impact — requires compromised CA to exploit. Tracked for upstream rustls-webpki upgrade. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(routines): use fields.join for cron normalization Use split_whitespace fields instead of re-trimming the original string to avoid preserving extra internal whitespace in cron expressions. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(repl): Apple-style approval card — clean vertical flow - Drop verbose tool description (the command IS the decision surface) - Unified vertical pipe layout: ◆ header → │ params → │ selector - Selector options show keyboard shortcuts inline: Approve (y) - Compact help message, answered state uses └ to close the flow - No horizontal rules, no blank-line padding — just breathing room Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(repl): replace inquire with crossterm for approval selector Drop the inquire dependency (which pulled in crossterm 0.25, duplicating the existing 0.28). The 3-option approval selector is now built directly with crossterm raw mode — same UX, zero new dependencies. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore(deps): upgrade crossterm 0.28 → 0.29, eliminate duplication termimad (via crokey) uses crossterm 0.29. Upgrading our direct dependency from 0.28 to 0.29 collapses to a single crossterm version in the dependency tree. Also migrated termimad::crossterm:: references to the direct crossterm import. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review comments — box_top off-by-one, smart_truncate overflow, mobile theme toggle - Fix box_top() fill calculation: was off-by-one, producing boxes 1 char too wide (fmt.rs) - Fix smart_truncate(): account for "..." in the budget so output never exceeds max_chars (repl.rs) - Move theme toggle to settings sidebar on mobile instead of display:none, so mobile users can still switch themes (style.css, index.html, app.js) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: cargo fmt repl.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review — retry duplication, CSP connect-src, deny color - Remove failed message before retry to prevent duplicate user messages - Revert connect-src to 'self' — CDN hosts only need script-src - Use red for Deny confirmation in REPL approval selector Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
393 lines
13 KiB
Rust
393 lines
13 KiB
Rust
//! Tunnel abstraction for exposing the agent to the internet.
|
|
//!
|
|
//! Wraps external tunnel binaries (cloudflared, ngrok, tailscale, etc.) behind
|
|
//! a common trait. The gateway starts a tunnel after binding its local port
|
|
//! and stops it on shutdown.
|
|
//!
|
|
//! Supported providers:
|
|
//! - **cloudflare** - Zero Trust tunnels via `cloudflared`
|
|
//! - **tailscale** - `tailscale serve` (tailnet) or `tailscale funnel` (public)
|
|
//! - **ngrok** - instant public URLs via `ngrok`
|
|
//! - **custom** - any command with `{host}`/`{port}` placeholders
|
|
//! - **none** - local-only, no external exposure
|
|
|
|
mod cloudflare;
|
|
mod custom;
|
|
mod ngrok;
|
|
mod none;
|
|
mod tailscale;
|
|
|
|
pub use cloudflare::CloudflareTunnel;
|
|
pub use custom::CustomTunnel;
|
|
pub use ngrok::NgrokTunnel;
|
|
pub use none::NoneTunnel;
|
|
pub use tailscale::TailscaleTunnel;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::{Result, bail};
|
|
use tokio::sync::Mutex;
|
|
|
|
/// Lock-free URL storage. Uses `std::sync::RwLock` so `public_url()` (sync)
|
|
/// never returns a spurious `None` due to async lock contention.
|
|
pub(crate) type SharedUrl = Arc<std::sync::RwLock<Option<String>>>;
|
|
|
|
pub(crate) fn new_shared_url() -> SharedUrl {
|
|
Arc::new(std::sync::RwLock::new(None))
|
|
}
|
|
|
|
// ── Tunnel trait ─────────────────────────────────────────────────
|
|
|
|
/// Provider-agnostic tunnel with lifecycle management.
|
|
///
|
|
/// Implementations wrap an external tunnel binary. The gateway calls
|
|
/// `start()` after binding its local port and `stop()` on shutdown.
|
|
#[async_trait::async_trait]
|
|
pub trait Tunnel: Send + Sync {
|
|
/// Human-readable provider name (e.g. "cloudflare", "tailscale").
|
|
fn name(&self) -> &str;
|
|
|
|
/// Start the tunnel exposing `local_host:local_port` externally.
|
|
/// Returns the public URL on success.
|
|
async fn start(&self, local_host: &str, local_port: u16) -> Result<String>;
|
|
|
|
/// Stop the tunnel process gracefully.
|
|
async fn stop(&self) -> Result<()>;
|
|
|
|
/// Check if the tunnel process is still alive.
|
|
async fn health_check(&self) -> bool;
|
|
|
|
/// Return the public URL if the tunnel is running, `None` otherwise.
|
|
fn public_url(&self) -> Option<String>;
|
|
}
|
|
|
|
// ── Shared child-process handle ──────────────────────────────────
|
|
|
|
/// Wraps a spawned tunnel child process.
|
|
pub(crate) struct TunnelProcess {
|
|
pub child: tokio::process::Child,
|
|
}
|
|
|
|
pub(crate) type SharedProcess = Arc<Mutex<Option<TunnelProcess>>>;
|
|
|
|
pub(crate) fn new_shared_process() -> SharedProcess {
|
|
Arc::new(Mutex::new(None))
|
|
}
|
|
|
|
/// Kill a shared tunnel process if running.
|
|
pub(crate) async fn kill_shared(proc: &SharedProcess) -> Result<()> {
|
|
let mut guard = proc.lock().await;
|
|
if let Some(ref mut tp) = *guard {
|
|
tp.child.kill().await.ok();
|
|
tp.child.wait().await.ok();
|
|
}
|
|
*guard = None;
|
|
Ok(())
|
|
}
|
|
|
|
// ── Configuration types ──────────────────────────────────────────
|
|
|
|
/// Provider-specific config for Cloudflare tunnels.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct CloudflareTunnelConfig {
|
|
/// Token from the Cloudflare Zero Trust dashboard.
|
|
pub token: String,
|
|
}
|
|
|
|
/// Provider-specific config for Tailscale tunnels.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct TailscaleTunnelConfig {
|
|
/// Use `tailscale funnel` (public) instead of `tailscale serve` (tailnet).
|
|
pub funnel: bool,
|
|
/// Override the hostname (default: auto-detect from `tailscale status`).
|
|
pub hostname: Option<String>,
|
|
}
|
|
|
|
/// Provider-specific config for ngrok tunnels.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct NgrokTunnelConfig {
|
|
/// ngrok auth token (required).
|
|
pub auth_token: String,
|
|
/// Custom domain (requires ngrok paid plan).
|
|
pub domain: Option<String>,
|
|
}
|
|
|
|
/// Provider-specific config for custom tunnel commands.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct CustomTunnelConfig {
|
|
/// Shell command with `{port}` and `{host}` placeholders.
|
|
pub start_command: String,
|
|
/// HTTP endpoint to poll for health checks.
|
|
pub health_url: Option<String>,
|
|
/// Substring to match in stdout for URL extraction.
|
|
pub url_pattern: Option<String>,
|
|
}
|
|
|
|
/// Full tunnel configuration.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct TunnelProviderConfig {
|
|
/// Provider name: "none", "cloudflare", "tailscale", "ngrok", "custom".
|
|
pub provider: String,
|
|
pub cloudflare: Option<CloudflareTunnelConfig>,
|
|
pub tailscale: Option<TailscaleTunnelConfig>,
|
|
pub ngrok: Option<NgrokTunnelConfig>,
|
|
pub custom: Option<CustomTunnelConfig>,
|
|
}
|
|
|
|
// ── Factory ──────────────────────────────────────────────────────
|
|
|
|
/// Create a tunnel from config. Returns `None` for provider "none" or empty.
|
|
pub fn create_tunnel(config: &TunnelProviderConfig) -> Result<Option<Box<dyn Tunnel>>> {
|
|
match config.provider.as_str() {
|
|
"none" | "" => Ok(None),
|
|
|
|
"cloudflare" => {
|
|
let cf = config.cloudflare.as_ref().ok_or_else(|| {
|
|
anyhow::anyhow!("TUNNEL_PROVIDER=cloudflare but no TUNNEL_CF_TOKEN configured")
|
|
})?;
|
|
Ok(Some(Box::new(CloudflareTunnel::new(cf.token.clone()))))
|
|
}
|
|
|
|
"tailscale" => {
|
|
let ts = config.tailscale.as_ref().cloned().unwrap_or_default();
|
|
Ok(Some(Box::new(TailscaleTunnel::new(ts.funnel, ts.hostname))))
|
|
}
|
|
|
|
"ngrok" => {
|
|
let ng = config.ngrok.as_ref().ok_or_else(|| {
|
|
anyhow::anyhow!("TUNNEL_PROVIDER=ngrok but no TUNNEL_NGROK_TOKEN configured")
|
|
})?;
|
|
Ok(Some(Box::new(NgrokTunnel::new(
|
|
ng.auth_token.clone(),
|
|
ng.domain.clone(),
|
|
))))
|
|
}
|
|
|
|
"custom" => {
|
|
let cu = config.custom.as_ref().ok_or_else(|| {
|
|
anyhow::anyhow!("TUNNEL_PROVIDER=custom but no TUNNEL_CUSTOM_COMMAND configured")
|
|
})?;
|
|
Ok(Some(Box::new(CustomTunnel::new(
|
|
cu.start_command.clone(),
|
|
cu.health_url.clone(),
|
|
cu.url_pattern.clone(),
|
|
))))
|
|
}
|
|
|
|
other => bail!(
|
|
"Unknown tunnel provider: \"{other}\". Valid: none, cloudflare, tailscale, ngrok, custom"
|
|
),
|
|
}
|
|
}
|
|
|
|
// ── Managed tunnel startup ───────────────────────────────────────
|
|
|
|
/// Start a managed tunnel if configured and no static URL is already set.
|
|
///
|
|
/// Returns the (potentially mutated) config with `tunnel.public_url` set,
|
|
/// plus the active tunnel handle (if one was started) for later shutdown.
|
|
pub async fn start_managed_tunnel(
|
|
mut config: crate::config::Config,
|
|
) -> (crate::config::Config, Option<Box<dyn Tunnel>>) {
|
|
if config.tunnel.public_url.is_some() {
|
|
tracing::debug!(
|
|
"Static tunnel URL in use: {}",
|
|
config.tunnel.public_url.as_deref().unwrap_or("?")
|
|
);
|
|
return (config, None);
|
|
}
|
|
|
|
let Some(ref provider_config) = config.tunnel.provider else {
|
|
return (config, None);
|
|
};
|
|
|
|
let gateway_port = config
|
|
.channels
|
|
.gateway
|
|
.as_ref()
|
|
.map(|g| g.port)
|
|
.unwrap_or(3000);
|
|
let gateway_host = config
|
|
.channels
|
|
.gateway
|
|
.as_ref()
|
|
.map(|g| g.host.as_str())
|
|
.unwrap_or("127.0.0.1");
|
|
|
|
match create_tunnel(provider_config) {
|
|
Ok(Some(tunnel)) => {
|
|
tracing::debug!(
|
|
"Starting {} tunnel on {}:{}...",
|
|
tunnel.name(),
|
|
gateway_host,
|
|
gateway_port
|
|
);
|
|
match tunnel.start(gateway_host, gateway_port).await {
|
|
Ok(url) => {
|
|
tracing::debug!("Tunnel started: {}", url);
|
|
config.tunnel.public_url = Some(url);
|
|
(config, Some(tunnel))
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Failed to start tunnel: {}", e);
|
|
(config, None)
|
|
}
|
|
}
|
|
}
|
|
Ok(None) => (config, None),
|
|
Err(e) => {
|
|
tracing::error!("Failed to create tunnel: {}", e);
|
|
(config, None)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tokio::process::Command;
|
|
|
|
fn assert_tunnel_err(cfg: &TunnelProviderConfig, needle: &str) {
|
|
match create_tunnel(cfg) {
|
|
Err(e) => assert!(
|
|
e.to_string().contains(needle),
|
|
"Expected error containing \"{needle}\", got: {e}"
|
|
),
|
|
Ok(_) => panic!("Expected error containing \"{needle}\", but got Ok"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn factory_none_returns_none() {
|
|
let cfg = TunnelProviderConfig::default();
|
|
assert!(create_tunnel(&cfg).unwrap().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn factory_empty_returns_none() {
|
|
let cfg = TunnelProviderConfig {
|
|
provider: String::new(),
|
|
..Default::default()
|
|
};
|
|
assert!(create_tunnel(&cfg).unwrap().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn factory_unknown_provider_errors() {
|
|
let cfg = TunnelProviderConfig {
|
|
provider: "wireguard".into(),
|
|
..Default::default()
|
|
};
|
|
assert_tunnel_err(&cfg, "Unknown tunnel provider");
|
|
}
|
|
|
|
#[test]
|
|
fn factory_cloudflare_missing_config_errors() {
|
|
let cfg = TunnelProviderConfig {
|
|
provider: "cloudflare".into(),
|
|
..Default::default()
|
|
};
|
|
assert_tunnel_err(&cfg, "TUNNEL_CF_TOKEN");
|
|
}
|
|
|
|
#[test]
|
|
fn factory_cloudflare_with_config_ok() {
|
|
use crate::testing::credentials::TEST_BEARER_TOKEN;
|
|
let cfg = TunnelProviderConfig {
|
|
provider: "cloudflare".into(),
|
|
cloudflare: Some(CloudflareTunnelConfig {
|
|
token: TEST_BEARER_TOKEN.into(),
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let t = create_tunnel(&cfg).unwrap().unwrap();
|
|
assert_eq!(t.name(), "cloudflare");
|
|
}
|
|
|
|
#[test]
|
|
fn factory_tailscale_defaults_ok() {
|
|
let cfg = TunnelProviderConfig {
|
|
provider: "tailscale".into(),
|
|
..Default::default()
|
|
};
|
|
let t = create_tunnel(&cfg).unwrap().unwrap();
|
|
assert_eq!(t.name(), "tailscale");
|
|
}
|
|
|
|
#[test]
|
|
fn factory_ngrok_missing_config_errors() {
|
|
let cfg = TunnelProviderConfig {
|
|
provider: "ngrok".into(),
|
|
..Default::default()
|
|
};
|
|
assert_tunnel_err(&cfg, "TUNNEL_NGROK_TOKEN");
|
|
}
|
|
|
|
#[test]
|
|
fn factory_ngrok_with_config_ok() {
|
|
let cfg = TunnelProviderConfig {
|
|
provider: "ngrok".into(),
|
|
ngrok: Some(NgrokTunnelConfig {
|
|
auth_token: "tok".into(),
|
|
domain: None,
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let t = create_tunnel(&cfg).unwrap().unwrap();
|
|
assert_eq!(t.name(), "ngrok");
|
|
}
|
|
|
|
#[test]
|
|
fn factory_custom_missing_config_errors() {
|
|
let cfg = TunnelProviderConfig {
|
|
provider: "custom".into(),
|
|
..Default::default()
|
|
};
|
|
assert_tunnel_err(&cfg, "TUNNEL_CUSTOM_COMMAND");
|
|
}
|
|
|
|
#[test]
|
|
fn factory_custom_with_config_ok() {
|
|
let cfg = TunnelProviderConfig {
|
|
provider: "custom".into(),
|
|
custom: Some(CustomTunnelConfig {
|
|
start_command: "echo tunnel".into(),
|
|
health_url: None,
|
|
url_pattern: None,
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let t = create_tunnel(&cfg).unwrap().unwrap();
|
|
assert_eq!(t.name(), "custom");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn kill_shared_no_process_is_ok() {
|
|
let proc = new_shared_process();
|
|
assert!(kill_shared(&proc).await.is_ok());
|
|
assert!(proc.lock().await.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn kill_shared_terminates_child() {
|
|
let proc = new_shared_process();
|
|
|
|
let child = Command::new("sleep")
|
|
.arg("30")
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.spawn()
|
|
.expect("sleep should spawn");
|
|
|
|
{
|
|
let mut guard = proc.lock().await;
|
|
*guard = Some(TunnelProcess { child });
|
|
}
|
|
|
|
kill_shared(&proc).await.unwrap();
|
|
assert!(proc.lock().await.is_none());
|
|
}
|
|
}
|