mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(extensions): unify auth and configure into single entrypoint Refactors the extension lifecycle to eliminate the divergence between chat and gateway paths that caused Telegram setup via chat to fail (missing webhook secret auto-generation, no token validation). Key changes: - Rename save_setup_secrets() → configure(): single entrypoint for providing secrets to any extension (WasmChannel, WasmTool, MCP). Validates, stores, auto-generates, and activates. - Add configure_token(): convenience wrapper for single-token callers (chat auth card, WebSocket, agent auth mode). - Refactor auth() to pure status check: remove token parameter, delete token-storing branches from auth_mcp/auth_wasm_tool, rename auth_wasm_channel → auth_wasm_channel_status. - Add ConfigureResult/MissingSecret types for structured responses. - Replace hardcoded Telegram token validation with generic validation_endpoint from capabilities.json. - Update all callers (9 files) to use the new interface. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: use ValidationFailed error variant instead of string matching Replace brittle msg.contains("Invalid token") checks with a proper ExtensionError::ValidationFailed variant. configure() now returns this variant for token validation failures, and callers match on it directly instead of parsing error message strings. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review — SSRF protection, error typing, missing-secret selection, WS auth 1. SSRF: call validate_fetch_url() before validation_endpoint HTTP request 2. Transport errors map to ExtensionError::Other (not ValidationFailed) 3. configure_token() picks first *missing* secret, not first non-optional 4. WebSocket error path re-emits AuthRequired on ValidationFailed Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * test: add regression tests for extension lifecycle refactoring - test_configure_token_picks_first_missing_secret: verifies multi-secret channels can be configured one secret at a time (commit ce106f4) - test_auth_is_read_only_for_wasm_channel: verifies auth() has no side effects and doesn't store secrets (commit 47f8eb6) - test_validation_failed_is_distinct_error_variant: verifies the typed error variant can be pattern-matched (commit a318161) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review comments — activation dispatch, dead code, caps consolidation - Fix configure() fallthrough bug: dispatch activation by ExtensionKind instead of unconditionally calling activate_wasm_channel() for all non-WasmTool types (MCP servers and channel relays now use their correct activation methods) - Remove dead MissingSecret struct and missing_secrets field (never populated, flagged by reviewer) - Consolidate capabilities file parsing in configure(): parse once and reuse for allowed names, validation_endpoint, and auto-generation - Fix auth() doc comment: note MCP OAuth side effects - Fix stale save_setup_secrets reference in server.rs comment - Add regression test for activation dispatch bug Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
3220 lines
109 KiB
Rust
3220 lines
109 KiB
Rust
//! Axum HTTP server for the web gateway.
|
|
//!
|
|
//! Handles all API routes: chat, memory, jobs, health, and static file serving.
|
|
|
|
use std::convert::Infallible;
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
use axum::{
|
|
Json, Router,
|
|
extract::{DefaultBodyLimit, Path, Query, State, WebSocketUpgrade},
|
|
http::{StatusCode, header},
|
|
middleware,
|
|
response::{
|
|
IntoResponse,
|
|
sse::{Event, KeepAlive, Sse},
|
|
},
|
|
routing::{get, post},
|
|
};
|
|
use serde::Deserialize;
|
|
use tokio::sync::{mpsc, oneshot};
|
|
use tokio_stream::StreamExt;
|
|
use tower_http::cors::{AllowHeaders, CorsLayer};
|
|
use tower_http::set_header::SetResponseHeaderLayer;
|
|
use uuid::Uuid;
|
|
|
|
use crate::agent::SessionManager;
|
|
use crate::bootstrap::ironclaw_base_dir;
|
|
use crate::channels::IncomingMessage;
|
|
use crate::channels::relay::DEFAULT_RELAY_NAME;
|
|
use crate::channels::web::auth::{AuthState, auth_middleware};
|
|
use crate::channels::web::handlers::jobs::{
|
|
job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler,
|
|
jobs_events_handler, jobs_list_handler, jobs_prompt_handler, jobs_restart_handler,
|
|
jobs_summary_handler,
|
|
};
|
|
use crate::channels::web::handlers::skills::{
|
|
skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler,
|
|
};
|
|
use crate::channels::web::log_layer::LogBroadcaster;
|
|
use crate::channels::web::sse::SseManager;
|
|
use crate::channels::web::types::*;
|
|
use crate::channels::web::util::{build_turns_from_db_messages, truncate_preview};
|
|
use crate::db::Database;
|
|
use crate::extensions::ExtensionManager;
|
|
use crate::orchestrator::job_manager::ContainerJobManager;
|
|
use crate::tools::ToolRegistry;
|
|
use crate::workspace::Workspace;
|
|
|
|
/// Shared prompt queue: maps job IDs to pending follow-up prompts for Claude Code bridges.
|
|
pub type PromptQueue = Arc<
|
|
tokio::sync::Mutex<
|
|
std::collections::HashMap<
|
|
uuid::Uuid,
|
|
std::collections::VecDeque<crate::orchestrator::api::PendingPrompt>,
|
|
>,
|
|
>,
|
|
>;
|
|
|
|
/// Slot for the routine engine, filled at runtime after the agent starts.
|
|
pub type RoutineEngineSlot =
|
|
Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>;
|
|
|
|
/// Simple sliding-window rate limiter.
|
|
///
|
|
/// Tracks the number of requests in the current window. Resets when the window expires.
|
|
/// Not per-IP (since this is a single-user gateway with auth), but prevents flooding.
|
|
pub struct RateLimiter {
|
|
/// Requests remaining in the current window.
|
|
remaining: AtomicU64,
|
|
/// Epoch second when the current window started.
|
|
window_start: AtomicU64,
|
|
/// Maximum requests per window.
|
|
max_requests: u64,
|
|
/// Window duration in seconds.
|
|
window_secs: u64,
|
|
}
|
|
|
|
impl RateLimiter {
|
|
pub fn new(max_requests: u64, window_secs: u64) -> Self {
|
|
Self {
|
|
remaining: AtomicU64::new(max_requests),
|
|
window_start: AtomicU64::new(
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs(),
|
|
),
|
|
max_requests,
|
|
window_secs,
|
|
}
|
|
}
|
|
|
|
/// Try to consume one request. Returns `true` if allowed, `false` if rate limited.
|
|
pub fn check(&self) -> bool {
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
|
|
let window = self.window_start.load(Ordering::Relaxed);
|
|
if now.saturating_sub(window) >= self.window_secs {
|
|
// Window expired, reset
|
|
self.window_start.store(now, Ordering::Relaxed);
|
|
self.remaining
|
|
.store(self.max_requests - 1, Ordering::Relaxed);
|
|
return true;
|
|
}
|
|
|
|
// Try to decrement remaining
|
|
loop {
|
|
let current = self.remaining.load(Ordering::Relaxed);
|
|
if current == 0 {
|
|
return false;
|
|
}
|
|
if self
|
|
.remaining
|
|
.compare_exchange_weak(current, current - 1, Ordering::Relaxed, Ordering::Relaxed)
|
|
.is_ok()
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Shared state for all gateway handlers.
|
|
pub struct GatewayState {
|
|
/// Channel to send messages to the agent loop.
|
|
pub msg_tx: tokio::sync::RwLock<Option<mpsc::Sender<IncomingMessage>>>,
|
|
/// SSE broadcast manager.
|
|
pub sse: SseManager,
|
|
/// Workspace for memory API.
|
|
pub workspace: Option<Arc<Workspace>>,
|
|
/// Session manager for thread info.
|
|
pub session_manager: Option<Arc<SessionManager>>,
|
|
/// Log broadcaster for the logs SSE endpoint.
|
|
pub log_broadcaster: Option<Arc<LogBroadcaster>>,
|
|
/// Handle for changing the tracing log level at runtime.
|
|
pub log_level_handle: Option<Arc<crate::channels::web::log_layer::LogLevelHandle>>,
|
|
/// Extension manager for extension management API.
|
|
pub extension_manager: Option<Arc<ExtensionManager>>,
|
|
/// Tool registry for listing registered tools.
|
|
pub tool_registry: Option<Arc<ToolRegistry>>,
|
|
/// Database store for sandbox job persistence.
|
|
pub store: Option<Arc<dyn Database>>,
|
|
/// Container job manager for sandbox operations.
|
|
pub job_manager: Option<Arc<ContainerJobManager>>,
|
|
/// Prompt queue for Claude Code follow-up prompts.
|
|
pub prompt_queue: Option<PromptQueue>,
|
|
/// User ID for this gateway.
|
|
pub user_id: String,
|
|
/// Shutdown signal sender.
|
|
pub shutdown_tx: tokio::sync::RwLock<Option<oneshot::Sender<()>>>,
|
|
/// WebSocket connection tracker.
|
|
pub ws_tracker: Option<Arc<crate::channels::web::ws::WsConnectionTracker>>,
|
|
/// LLM provider for OpenAI-compatible API proxy.
|
|
pub llm_provider: Option<Arc<dyn crate::llm::LlmProvider>>,
|
|
/// Skill registry for skill management API.
|
|
pub skill_registry: Option<Arc<std::sync::RwLock<crate::skills::SkillRegistry>>>,
|
|
/// Skill catalog for searching the ClawHub registry.
|
|
pub skill_catalog: Option<Arc<crate::skills::catalog::SkillCatalog>>,
|
|
/// Scheduler for sending follow-up messages to running agent jobs.
|
|
pub scheduler: Option<crate::tools::builtin::SchedulerSlot>,
|
|
/// Rate limiter for chat endpoints (30 messages per 60 seconds).
|
|
pub chat_rate_limiter: RateLimiter,
|
|
/// Rate limiter for OAuth callback endpoints (10 requests per 60 seconds).
|
|
pub oauth_rate_limiter: RateLimiter,
|
|
/// Registry catalog entries for the available extensions API.
|
|
/// Populated at startup from `registry/` manifests, independent of extension manager.
|
|
pub registry_entries: Vec<crate::extensions::RegistryEntry>,
|
|
/// Cost guard for token/cost tracking.
|
|
pub cost_guard: Option<Arc<crate::agent::cost_guard::CostGuard>>,
|
|
/// Routine engine slot for manual routine triggering (filled at runtime).
|
|
pub routine_engine: RoutineEngineSlot,
|
|
/// Server startup time for uptime calculation.
|
|
pub startup_time: std::time::Instant,
|
|
}
|
|
|
|
/// Start the gateway HTTP server.
|
|
///
|
|
/// Returns the actual bound `SocketAddr` (useful when binding to port 0).
|
|
pub async fn start_server(
|
|
addr: SocketAddr,
|
|
state: Arc<GatewayState>,
|
|
auth_token: String,
|
|
) -> Result<SocketAddr, crate::error::ChannelError> {
|
|
let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| {
|
|
crate::error::ChannelError::StartupFailed {
|
|
name: "gateway".to_string(),
|
|
reason: format!("Failed to bind to {}: {}", addr, e),
|
|
}
|
|
})?;
|
|
let bound_addr =
|
|
listener
|
|
.local_addr()
|
|
.map_err(|e| crate::error::ChannelError::StartupFailed {
|
|
name: "gateway".to_string(),
|
|
reason: format!("Failed to get local addr: {}", e),
|
|
})?;
|
|
|
|
// Public routes (no auth)
|
|
let public = Router::new()
|
|
.route("/api/health", get(health_handler))
|
|
.route("/oauth/callback", get(oauth_callback_handler))
|
|
.route(
|
|
"/oauth/slack/callback",
|
|
get(slack_relay_oauth_callback_handler),
|
|
);
|
|
|
|
// Protected routes (require auth)
|
|
let auth_state = AuthState { token: auth_token };
|
|
let protected = Router::new()
|
|
// Chat
|
|
.route("/api/chat/send", post(chat_send_handler))
|
|
.route("/api/chat/approval", post(chat_approval_handler))
|
|
.route("/api/chat/auth-token", post(chat_auth_token_handler))
|
|
.route("/api/chat/auth-cancel", post(chat_auth_cancel_handler))
|
|
.route("/api/chat/events", get(chat_events_handler))
|
|
.route("/api/chat/ws", get(chat_ws_handler))
|
|
.route("/api/chat/history", get(chat_history_handler))
|
|
.route("/api/chat/threads", get(chat_threads_handler))
|
|
.route("/api/chat/thread/new", post(chat_new_thread_handler))
|
|
// Memory
|
|
.route("/api/memory/tree", get(memory_tree_handler))
|
|
.route("/api/memory/list", get(memory_list_handler))
|
|
.route("/api/memory/read", get(memory_read_handler))
|
|
.route("/api/memory/write", post(memory_write_handler))
|
|
.route("/api/memory/search", post(memory_search_handler))
|
|
// Jobs
|
|
.route("/api/jobs", get(jobs_list_handler))
|
|
.route("/api/jobs/summary", get(jobs_summary_handler))
|
|
.route("/api/jobs/{id}", get(jobs_detail_handler))
|
|
.route("/api/jobs/{id}/cancel", post(jobs_cancel_handler))
|
|
.route("/api/jobs/{id}/restart", post(jobs_restart_handler))
|
|
.route("/api/jobs/{id}/prompt", post(jobs_prompt_handler))
|
|
.route("/api/jobs/{id}/events", get(jobs_events_handler))
|
|
.route("/api/jobs/{id}/files/list", get(job_files_list_handler))
|
|
.route("/api/jobs/{id}/files/read", get(job_files_read_handler))
|
|
// Logs
|
|
.route("/api/logs/events", get(logs_events_handler))
|
|
.route("/api/logs/level", get(logs_level_get_handler))
|
|
.route(
|
|
"/api/logs/level",
|
|
axum::routing::put(logs_level_set_handler),
|
|
)
|
|
// Extensions
|
|
.route("/api/extensions", get(extensions_list_handler))
|
|
.route("/api/extensions/tools", get(extensions_tools_handler))
|
|
.route("/api/extensions/registry", get(extensions_registry_handler))
|
|
.route("/api/extensions/install", post(extensions_install_handler))
|
|
.route(
|
|
"/api/extensions/{name}/activate",
|
|
post(extensions_activate_handler),
|
|
)
|
|
.route(
|
|
"/api/extensions/{name}/remove",
|
|
post(extensions_remove_handler),
|
|
)
|
|
.route(
|
|
"/api/extensions/{name}/setup",
|
|
get(extensions_setup_handler).post(extensions_setup_submit_handler),
|
|
)
|
|
// Pairing
|
|
.route("/api/pairing/{channel}", get(pairing_list_handler))
|
|
.route(
|
|
"/api/pairing/{channel}/approve",
|
|
post(pairing_approve_handler),
|
|
)
|
|
// Routines
|
|
.route("/api/routines", get(routines_list_handler))
|
|
.route("/api/routines/summary", get(routines_summary_handler))
|
|
.route("/api/routines/{id}", get(routines_detail_handler))
|
|
.route("/api/routines/{id}/trigger", post(routines_trigger_handler))
|
|
.route("/api/routines/{id}/toggle", post(routines_toggle_handler))
|
|
.route(
|
|
"/api/routines/{id}",
|
|
axum::routing::delete(routines_delete_handler),
|
|
)
|
|
.route("/api/routines/{id}/runs", get(routines_runs_handler))
|
|
// Skills
|
|
.route("/api/skills", get(skills_list_handler))
|
|
.route("/api/skills/search", post(skills_search_handler))
|
|
.route("/api/skills/install", post(skills_install_handler))
|
|
.route(
|
|
"/api/skills/{name}",
|
|
axum::routing::delete(skills_remove_handler),
|
|
)
|
|
// Settings
|
|
.route("/api/settings", get(settings_list_handler))
|
|
.route("/api/settings/export", get(settings_export_handler))
|
|
.route("/api/settings/import", post(settings_import_handler))
|
|
.route("/api/settings/{key}", get(settings_get_handler))
|
|
.route(
|
|
"/api/settings/{key}",
|
|
axum::routing::put(settings_set_handler),
|
|
)
|
|
.route(
|
|
"/api/settings/{key}",
|
|
axum::routing::delete(settings_delete_handler),
|
|
)
|
|
// Gateway control plane
|
|
.route("/api/gateway/status", get(gateway_status_handler))
|
|
// OpenAI-compatible API
|
|
.route(
|
|
"/v1/chat/completions",
|
|
post(super::openai_compat::chat_completions_handler),
|
|
)
|
|
.route("/v1/models", get(super::openai_compat::models_handler))
|
|
.route_layer(middleware::from_fn_with_state(
|
|
auth_state.clone(),
|
|
auth_middleware,
|
|
));
|
|
|
|
// Static file routes (no auth, served from embedded strings)
|
|
let statics = Router::new()
|
|
.route("/", get(index_handler))
|
|
.route("/style.css", get(css_handler))
|
|
.route("/app.js", get(js_handler))
|
|
.route("/favicon.ico", get(favicon_handler))
|
|
.route("/i18n/index.js", get(i18n_index_handler))
|
|
.route("/i18n/en.js", get(i18n_en_handler))
|
|
.route("/i18n/zh-CN.js", get(i18n_zh_handler))
|
|
.route("/i18n-app.js", get(i18n_app_handler));
|
|
|
|
// Project file serving (behind auth to prevent unauthorized file access).
|
|
let projects = Router::new()
|
|
.route("/projects/{project_id}", get(project_redirect_handler))
|
|
.route("/projects/{project_id}/", get(project_index_handler))
|
|
.route("/projects/{project_id}/{*path}", get(project_file_handler))
|
|
.route_layer(middleware::from_fn_with_state(
|
|
auth_state.clone(),
|
|
auth_middleware,
|
|
));
|
|
|
|
// CORS: restrict to same-origin by default. Only localhost/127.0.0.1
|
|
// origins are allowed, since the gateway is a local-first service.
|
|
let cors = CorsLayer::new()
|
|
.allow_origin([
|
|
format!("http://{}:{}", addr.ip(), addr.port())
|
|
.parse()
|
|
.expect("valid origin"),
|
|
format!("http://localhost:{}", addr.port())
|
|
.parse()
|
|
.expect("valid origin"),
|
|
])
|
|
.allow_methods([
|
|
axum::http::Method::GET,
|
|
axum::http::Method::POST,
|
|
axum::http::Method::PUT,
|
|
axum::http::Method::DELETE,
|
|
])
|
|
.allow_headers(AllowHeaders::list([
|
|
header::CONTENT_TYPE,
|
|
header::AUTHORIZATION,
|
|
]))
|
|
.allow_credentials(true);
|
|
|
|
let app = Router::new()
|
|
.merge(public)
|
|
.merge(statics)
|
|
.merge(projects)
|
|
.merge(protected)
|
|
.layer(DefaultBodyLimit::max(10 * 1024 * 1024)) // 10 MB max request body (image uploads)
|
|
.layer(cors)
|
|
.layer(SetResponseHeaderLayer::if_not_present(
|
|
header::X_CONTENT_TYPE_OPTIONS,
|
|
header::HeaderValue::from_static("nosniff"),
|
|
))
|
|
.layer(SetResponseHeaderLayer::if_not_present(
|
|
header::X_FRAME_OPTIONS,
|
|
header::HeaderValue::from_static("DENY"),
|
|
))
|
|
.with_state(state.clone());
|
|
|
|
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
|
*state.shutdown_tx.write().await = Some(shutdown_tx);
|
|
|
|
tokio::spawn(async move {
|
|
if let Err(e) = axum::serve(listener, app)
|
|
.with_graceful_shutdown(async {
|
|
let _ = shutdown_rx.await;
|
|
tracing::debug!("Web gateway shutting down");
|
|
})
|
|
.await
|
|
{
|
|
tracing::error!("Web gateway server error: {}", e);
|
|
}
|
|
});
|
|
|
|
Ok(bound_addr)
|
|
}
|
|
|
|
// --- Static file handlers ---
|
|
|
|
async fn index_handler() -> impl IntoResponse {
|
|
(
|
|
[
|
|
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
|
|
(header::CACHE_CONTROL, "no-cache"),
|
|
],
|
|
include_str!("static/index.html"),
|
|
)
|
|
}
|
|
|
|
async fn css_handler() -> impl IntoResponse {
|
|
(
|
|
[
|
|
(header::CONTENT_TYPE, "text/css"),
|
|
(header::CACHE_CONTROL, "no-cache"),
|
|
],
|
|
include_str!("static/style.css"),
|
|
)
|
|
}
|
|
|
|
async fn js_handler() -> impl IntoResponse {
|
|
(
|
|
[
|
|
(header::CONTENT_TYPE, "application/javascript"),
|
|
(header::CACHE_CONTROL, "no-cache"),
|
|
],
|
|
include_str!("static/app.js"),
|
|
)
|
|
}
|
|
|
|
async fn favicon_handler() -> impl IntoResponse {
|
|
(
|
|
[
|
|
(header::CONTENT_TYPE, "image/x-icon"),
|
|
(header::CACHE_CONTROL, "public, max-age=86400"),
|
|
],
|
|
include_bytes!("static/favicon.ico").as_slice(),
|
|
)
|
|
}
|
|
|
|
async fn i18n_index_handler() -> impl IntoResponse {
|
|
(
|
|
[
|
|
(header::CONTENT_TYPE, "application/javascript"),
|
|
(header::CACHE_CONTROL, "no-cache"),
|
|
],
|
|
include_str!("static/i18n/index.js"),
|
|
)
|
|
}
|
|
|
|
async fn i18n_en_handler() -> impl IntoResponse {
|
|
(
|
|
[
|
|
(header::CONTENT_TYPE, "application/javascript"),
|
|
(header::CACHE_CONTROL, "no-cache"),
|
|
],
|
|
include_str!("static/i18n/en.js"),
|
|
)
|
|
}
|
|
|
|
async fn i18n_zh_handler() -> impl IntoResponse {
|
|
(
|
|
[
|
|
(header::CONTENT_TYPE, "application/javascript"),
|
|
(header::CACHE_CONTROL, "no-cache"),
|
|
],
|
|
include_str!("static/i18n/zh-CN.js"),
|
|
)
|
|
}
|
|
|
|
async fn i18n_app_handler() -> impl IntoResponse {
|
|
(
|
|
[
|
|
(header::CONTENT_TYPE, "application/javascript"),
|
|
(header::CACHE_CONTROL, "no-cache"),
|
|
],
|
|
include_str!("static/i18n-app.js"),
|
|
)
|
|
}
|
|
|
|
// --- Health ---
|
|
|
|
async fn health_handler() -> Json<HealthResponse> {
|
|
Json(HealthResponse {
|
|
status: "healthy",
|
|
channel: "gateway",
|
|
})
|
|
}
|
|
|
|
/// Return an OAuth error landing page response.
|
|
fn oauth_error_page(label: &str) -> axum::response::Response {
|
|
let html = crate::cli::oauth_defaults::landing_html(label, false);
|
|
axum::response::Html(html).into_response()
|
|
}
|
|
|
|
/// OAuth callback handler for the web gateway.
|
|
///
|
|
/// This is a PUBLIC route (no Bearer token required) because OAuth providers
|
|
/// redirect the user's browser here. The `state` query parameter correlates
|
|
/// the callback with a pending OAuth flow registered by `start_wasm_oauth()`.
|
|
///
|
|
/// Used on hosted instances where `IRONCLAW_OAUTH_CALLBACK_URL` points to
|
|
/// the gateway (e.g., `https://kind-deer.agent1.near.ai/oauth/callback`).
|
|
/// Local/desktop mode continues to use the TCP listener on port 9876.
|
|
async fn oauth_callback_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
|
) -> impl IntoResponse {
|
|
use crate::cli::oauth_defaults;
|
|
|
|
// Check for error from OAuth provider (e.g., user denied consent)
|
|
if let Some(error) = params.get("error") {
|
|
let description = params
|
|
.get("error_description")
|
|
.cloned()
|
|
.unwrap_or_else(|| error.clone());
|
|
return oauth_error_page(&description);
|
|
}
|
|
|
|
let state_param = match params.get("state") {
|
|
Some(s) if !s.is_empty() => s.clone(),
|
|
_ => return oauth_error_page("IronClaw"),
|
|
};
|
|
|
|
let code = match params.get("code") {
|
|
Some(c) if !c.is_empty() => c.clone(),
|
|
_ => return oauth_error_page("IronClaw"),
|
|
};
|
|
|
|
// Look up the pending flow by CSRF state (atomic remove prevents replay)
|
|
let ext_mgr = match state.extension_manager.as_ref() {
|
|
Some(mgr) => mgr,
|
|
None => return oauth_error_page("IronClaw"),
|
|
};
|
|
|
|
// Strip instance prefix from state for registry lookup.
|
|
// Platform nginx sends `state=instance:nonce` but flows are keyed by nonce only.
|
|
let lookup_key = oauth_defaults::strip_instance_prefix(&state_param);
|
|
|
|
let flow = ext_mgr
|
|
.pending_oauth_flows()
|
|
.write()
|
|
.await
|
|
.remove(lookup_key);
|
|
|
|
let flow = match flow {
|
|
Some(f) => f,
|
|
None => {
|
|
tracing::warn!(
|
|
state = %state_param,
|
|
lookup_key = %lookup_key,
|
|
"OAuth callback received with unknown or expired state"
|
|
);
|
|
return oauth_error_page("IronClaw");
|
|
}
|
|
};
|
|
|
|
// Check flow expiry (5 minutes, matching TCP listener timeout)
|
|
if flow.created_at.elapsed() > oauth_defaults::OAUTH_FLOW_EXPIRY {
|
|
tracing::warn!(
|
|
extension = %flow.extension_name,
|
|
"OAuth flow expired"
|
|
);
|
|
return oauth_error_page(&flow.display_name);
|
|
}
|
|
|
|
// Exchange the authorization code for tokens.
|
|
// Use the platform exchange proxy when configured (keeps client_secret off container),
|
|
// otherwise call the provider's token URL directly.
|
|
let exchange_proxy_url = std::env::var("IRONCLAW_OAUTH_EXCHANGE_URL").ok();
|
|
|
|
let result: Result<(), String> = async {
|
|
let token_response = if let Some(ref proxy_url) = exchange_proxy_url {
|
|
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
|
|
oauth_defaults::exchange_via_proxy(
|
|
proxy_url,
|
|
gateway_token,
|
|
&code,
|
|
&flow.redirect_uri,
|
|
flow.code_verifier.as_deref(),
|
|
&flow.access_token_field,
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
} else {
|
|
oauth_defaults::exchange_oauth_code(
|
|
&flow.token_url,
|
|
&flow.client_id,
|
|
flow.client_secret.as_deref(),
|
|
&code,
|
|
&flow.redirect_uri,
|
|
flow.code_verifier.as_deref(),
|
|
&flow.access_token_field,
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
};
|
|
|
|
// Validate the token before storing (catches wrong account, etc.)
|
|
if let Some(ref validation) = flow.validation_endpoint {
|
|
oauth_defaults::validate_oauth_token(&token_response.access_token, validation)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
|
|
// Store tokens encrypted in the secrets store
|
|
oauth_defaults::store_oauth_tokens(
|
|
flow.secrets.as_ref(),
|
|
&flow.user_id,
|
|
&flow.secret_name,
|
|
flow.provider.as_deref(),
|
|
&token_response.access_token,
|
|
token_response.refresh_token.as_deref(),
|
|
token_response.expires_in,
|
|
&flow.scopes,
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
Ok(())
|
|
}
|
|
.await;
|
|
|
|
let (success, message) = match &result {
|
|
Ok(()) => (
|
|
true,
|
|
format!("{} authenticated successfully", flow.display_name),
|
|
),
|
|
Err(e) => (
|
|
false,
|
|
format!("{} authentication failed: {}", flow.display_name, e),
|
|
),
|
|
};
|
|
|
|
match &result {
|
|
Ok(()) => {
|
|
tracing::info!(
|
|
extension = %flow.extension_name,
|
|
"OAuth completed successfully via gateway callback"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
extension = %flow.extension_name,
|
|
error = %e,
|
|
"OAuth failed via gateway callback"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Broadcast SSE event to notify the web UI
|
|
if let Some(ref sender) = flow.sse_sender {
|
|
let _ = sender.send(SseEvent::AuthCompleted {
|
|
extension_name: flow.extension_name,
|
|
success,
|
|
message,
|
|
});
|
|
}
|
|
|
|
let html = oauth_defaults::landing_html(&flow.display_name, success);
|
|
axum::response::Html(html).into_response()
|
|
}
|
|
|
|
/// OAuth callback for Slack via channel-relay.
|
|
///
|
|
/// This is a PUBLIC route (no Bearer token required) because channel-relay
|
|
/// redirects the user's browser here after Slack OAuth completes.
|
|
/// Query params: `stream_token`, `provider`, `team_id`.
|
|
async fn slack_relay_oauth_callback_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
|
) -> impl IntoResponse {
|
|
// Rate limit
|
|
if !state.oauth_rate_limiter.check() {
|
|
return axum::response::Html(
|
|
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
|
|
<h2>Too Many Requests</h2>\
|
|
<p>Please try again later.</p>\
|
|
</body></html>"
|
|
.to_string(),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
// Validate stream_token: required, non-empty, max 2048 bytes
|
|
let stream_token = match params.get("stream_token") {
|
|
Some(t) if !t.is_empty() && t.len() <= 2048 => t.clone(),
|
|
Some(t) if t.len() > 2048 => {
|
|
return axum::response::Html(
|
|
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
|
|
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
|
|
.to_string(),
|
|
)
|
|
.into_response();
|
|
}
|
|
_ => {
|
|
return axum::response::Html(
|
|
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
|
|
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
|
|
.to_string(),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
// Validate team_id format: empty or T followed by alphanumeric (max 20 chars)
|
|
let team_id = params.get("team_id").cloned().unwrap_or_default();
|
|
if !team_id.is_empty() {
|
|
let valid_team_id = team_id.len() <= 21
|
|
&& team_id.starts_with('T')
|
|
&& team_id[1..].chars().all(|c| c.is_ascii_alphanumeric());
|
|
if !valid_team_id {
|
|
return axum::response::Html(
|
|
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
|
|
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
|
|
.to_string(),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
// Validate provider: must be "slack" (only supported provider)
|
|
let provider = params
|
|
.get("provider")
|
|
.cloned()
|
|
.unwrap_or_else(|| "slack".into());
|
|
if provider != "slack" {
|
|
return axum::response::Html(
|
|
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
|
|
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
|
|
.to_string(),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let ext_mgr = match state.extension_manager.as_ref() {
|
|
Some(mgr) => mgr,
|
|
None => {
|
|
return axum::response::Html(
|
|
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
|
|
<h2>Error</h2><p>Extension manager not available.</p></body></html>"
|
|
.to_string(),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
// Validate CSRF state parameter
|
|
let state_param = match params.get("state") {
|
|
Some(s) if !s.is_empty() && s.len() <= 128 => s.clone(),
|
|
_ => {
|
|
return axum::response::Html(
|
|
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
|
|
<h2>Error</h2><p>Invalid or expired authorization.</p></body></html>"
|
|
.to_string(),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
let state_key = format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME);
|
|
let stored_state = match ext_mgr
|
|
.secrets()
|
|
.get_decrypted(&state.user_id, &state_key)
|
|
.await
|
|
{
|
|
Ok(secret) => secret.expose().to_string(),
|
|
Err(_) => {
|
|
return axum::response::Html(
|
|
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
|
|
<h2>Error</h2><p>Invalid or expired authorization.</p></body></html>"
|
|
.to_string(),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
if state_param != stored_state {
|
|
return axum::response::Html(
|
|
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
|
|
<h2>Error</h2><p>Invalid or expired authorization.</p></body></html>"
|
|
.to_string(),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
// Delete the nonce (one-time use)
|
|
let _ = ext_mgr.secrets().delete(&state.user_id, &state_key).await;
|
|
|
|
let result: Result<(), String> = async {
|
|
// Store the stream token as a secret
|
|
let token_key = format!("relay:{}:stream_token", DEFAULT_RELAY_NAME);
|
|
let _ = ext_mgr.secrets().delete(&state.user_id, &token_key).await;
|
|
ext_mgr
|
|
.secrets()
|
|
.create(
|
|
&state.user_id,
|
|
crate::secrets::CreateSecretParams {
|
|
name: token_key,
|
|
value: secrecy::SecretString::from(stream_token),
|
|
provider: Some(provider.clone()),
|
|
expires_at: None,
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| format!("Failed to store stream token: {}", e))?;
|
|
|
|
// Store team_id in settings
|
|
if let Some(ref store) = state.store {
|
|
let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
|
|
let _ = store
|
|
.set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id))
|
|
.await;
|
|
}
|
|
|
|
// Activate the relay channel
|
|
ext_mgr
|
|
.activate_stored_relay(DEFAULT_RELAY_NAME)
|
|
.await
|
|
.map_err(|e| format!("Failed to activate relay channel: {}", e))?;
|
|
|
|
Ok(())
|
|
}
|
|
.await;
|
|
|
|
let (success, message) = match &result {
|
|
Ok(()) => (true, "Slack connected successfully!".to_string()),
|
|
Err(e) => {
|
|
tracing::error!(error = %e, "Slack relay OAuth callback failed");
|
|
(
|
|
false,
|
|
"Connection failed. Check server logs for details.".to_string(),
|
|
)
|
|
}
|
|
};
|
|
|
|
// Broadcast SSE event to notify the web UI
|
|
state.sse.broadcast(SseEvent::AuthCompleted {
|
|
extension_name: DEFAULT_RELAY_NAME.to_string(),
|
|
success,
|
|
message: message.clone(),
|
|
});
|
|
|
|
if success {
|
|
axum::response::Html(
|
|
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
|
|
<h2>Slack Connected!</h2>\
|
|
<p>You can close this tab and return to IronClaw.</p>\
|
|
<script>window.close()</script>\
|
|
</body></html>"
|
|
.to_string(),
|
|
)
|
|
.into_response()
|
|
} else {
|
|
axum::response::Html(format!(
|
|
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
|
|
<h2>Connection Failed</h2>\
|
|
<p>{}</p>\
|
|
</body></html>",
|
|
message
|
|
))
|
|
.into_response()
|
|
}
|
|
}
|
|
|
|
// --- Chat handlers ---
|
|
|
|
/// Convert web gateway `ImageData` to `IncomingAttachment` objects.
|
|
pub(crate) fn images_to_attachments(
|
|
images: &[ImageData],
|
|
) -> Vec<crate::channels::IncomingAttachment> {
|
|
use base64::Engine;
|
|
images
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(i, img)| {
|
|
if !img.media_type.starts_with("image/") {
|
|
tracing::warn!(
|
|
"Skipping image {i}: invalid media type '{}' (must start with 'image/')",
|
|
img.media_type
|
|
);
|
|
return None;
|
|
}
|
|
let data = match base64::engine::general_purpose::STANDARD.decode(&img.data) {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
tracing::warn!("Skipping image {i}: invalid base64 data: {e}");
|
|
return None;
|
|
}
|
|
};
|
|
Some(crate::channels::IncomingAttachment {
|
|
id: format!("web-image-{i}"),
|
|
kind: crate::channels::AttachmentKind::Image,
|
|
mime_type: img.media_type.clone(),
|
|
filename: Some(format!("image-{i}.{}", mime_to_ext(&img.media_type))),
|
|
size_bytes: Some(data.len() as u64),
|
|
source_url: None,
|
|
storage_key: None,
|
|
extracted_text: None,
|
|
data,
|
|
duration_secs: None,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Map MIME type to file extension.
|
|
fn mime_to_ext(mime: &str) -> &str {
|
|
match mime {
|
|
"image/png" => "png",
|
|
"image/gif" => "gif",
|
|
"image/webp" => "webp",
|
|
"image/svg+xml" => "svg",
|
|
_ => "jpg",
|
|
}
|
|
}
|
|
|
|
async fn chat_send_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
headers: axum::http::HeaderMap,
|
|
Json(req): Json<SendMessageRequest>,
|
|
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
|
|
tracing::trace!(
|
|
"[chat_send_handler] Received message: content_len={}, thread_id={:?}",
|
|
req.content.len(),
|
|
req.thread_id
|
|
);
|
|
|
|
if !state.chat_rate_limiter.check() {
|
|
return Err((
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
"Rate limit exceeded. Try again shortly.".to_string(),
|
|
));
|
|
}
|
|
|
|
let mut msg = IncomingMessage::new("gateway", &state.user_id, &req.content);
|
|
// Prefer timezone from JSON body, fall back to X-Timezone header
|
|
let tz = req
|
|
.timezone
|
|
.as_deref()
|
|
.or_else(|| headers.get("X-Timezone").and_then(|v| v.to_str().ok()));
|
|
if let Some(tz) = tz {
|
|
msg = msg.with_timezone(tz);
|
|
}
|
|
|
|
if let Some(ref thread_id) = req.thread_id {
|
|
msg = msg.with_thread(thread_id);
|
|
msg = msg.with_metadata(serde_json::json!({"thread_id": thread_id}));
|
|
}
|
|
|
|
// Convert uploaded images to IncomingAttachments
|
|
if !req.images.is_empty() {
|
|
let attachments = images_to_attachments(&req.images);
|
|
msg = msg.with_attachments(attachments);
|
|
}
|
|
|
|
let msg_id = msg.id;
|
|
tracing::trace!(
|
|
"[chat_send_handler] Created message id={}, content_len={}, images={}",
|
|
msg_id,
|
|
req.content.len(),
|
|
req.images.len()
|
|
);
|
|
|
|
let tx_guard = state.msg_tx.read().await;
|
|
let tx = tx_guard.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Channel not started".to_string(),
|
|
))?;
|
|
|
|
tracing::debug!("[chat_send_handler] Sending message through channel");
|
|
tx.send(msg).await.map_err(|_| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"Channel closed".to_string(),
|
|
)
|
|
})?;
|
|
|
|
tracing::debug!("[chat_send_handler] Message sent successfully, returning 202 ACCEPTED");
|
|
|
|
Ok((
|
|
StatusCode::ACCEPTED,
|
|
Json(SendMessageResponse {
|
|
message_id: msg_id,
|
|
status: "accepted",
|
|
}),
|
|
))
|
|
}
|
|
|
|
async fn chat_approval_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Json(req): Json<ApprovalRequest>,
|
|
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
|
|
let (approved, always) = match req.action.as_str() {
|
|
"approve" => (true, false),
|
|
"always" => (true, true),
|
|
"deny" => (false, false),
|
|
other => {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
format!("Unknown action: {}", other),
|
|
));
|
|
}
|
|
};
|
|
|
|
let request_id = Uuid::parse_str(&req.request_id).map_err(|_| {
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
"Invalid request_id (expected UUID)".to_string(),
|
|
)
|
|
})?;
|
|
|
|
// Build a structured ExecApproval submission as JSON, sent through the
|
|
// existing message pipeline so the agent loop picks it up.
|
|
let approval = crate::agent::submission::Submission::ExecApproval {
|
|
request_id,
|
|
approved,
|
|
always,
|
|
};
|
|
let content = serde_json::to_string(&approval).map_err(|e| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to serialize approval: {}", e),
|
|
)
|
|
})?;
|
|
|
|
let mut msg = IncomingMessage::new("gateway", &state.user_id, content);
|
|
|
|
if let Some(ref thread_id) = req.thread_id {
|
|
msg = msg.with_thread(thread_id);
|
|
}
|
|
|
|
let msg_id = msg.id;
|
|
|
|
let tx_guard = state.msg_tx.read().await;
|
|
let tx = tx_guard.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Channel not started".to_string(),
|
|
))?;
|
|
|
|
tx.send(msg).await.map_err(|_| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"Channel closed".to_string(),
|
|
)
|
|
})?;
|
|
|
|
Ok((
|
|
StatusCode::ACCEPTED,
|
|
Json(SendMessageResponse {
|
|
message_id: msg_id,
|
|
status: "accepted",
|
|
}),
|
|
))
|
|
}
|
|
|
|
/// Submit an auth token directly to the extension manager, bypassing the message pipeline.
|
|
///
|
|
/// The token never touches the LLM, chat history, or SSE stream.
|
|
async fn chat_auth_token_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Json(req): Json<AuthTokenRequest>,
|
|
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
|
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Extension manager not available".to_string(),
|
|
))?;
|
|
|
|
match ext_mgr
|
|
.configure_token(&req.extension_name, &req.token)
|
|
.await
|
|
{
|
|
Ok(result) => {
|
|
// Clear auth mode on the active thread
|
|
clear_auth_mode(&state).await;
|
|
|
|
state.sse.broadcast(SseEvent::AuthCompleted {
|
|
extension_name: req.extension_name.clone(),
|
|
success: true,
|
|
message: result.message.clone(),
|
|
});
|
|
|
|
Ok(Json(ActionResponse::ok(result.message)))
|
|
}
|
|
Err(e) => {
|
|
let msg = e.to_string();
|
|
// Re-emit auth_required for retry on validation errors
|
|
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
|
|
state.sse.broadcast(SseEvent::AuthRequired {
|
|
extension_name: req.extension_name.clone(),
|
|
instructions: Some(msg.clone()),
|
|
auth_url: None,
|
|
setup_url: None,
|
|
});
|
|
}
|
|
Ok(Json(ActionResponse::fail(msg)))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cancel an in-progress auth flow.
|
|
async fn chat_auth_cancel_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Json(_req): Json<AuthCancelRequest>,
|
|
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
|
clear_auth_mode(&state).await;
|
|
Ok(Json(ActionResponse::ok("Auth cancelled")))
|
|
}
|
|
|
|
/// Clear pending auth mode on the active thread.
|
|
pub async fn clear_auth_mode(state: &GatewayState) {
|
|
if let Some(ref sm) = state.session_manager {
|
|
let session = sm.get_or_create_session(&state.user_id).await;
|
|
let mut sess = session.lock().await;
|
|
if let Some(thread_id) = sess.active_thread
|
|
&& let Some(thread) = sess.threads.get_mut(&thread_id)
|
|
{
|
|
thread.pending_auth = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn chat_events_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
|
let sse = state.sse.subscribe().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Too many connections".to_string(),
|
|
))?;
|
|
Ok((
|
|
[("X-Accel-Buffering", "no"), ("Cache-Control", "no-cache")],
|
|
sse,
|
|
))
|
|
}
|
|
|
|
async fn chat_ws_handler(
|
|
headers: axum::http::HeaderMap,
|
|
ws: WebSocketUpgrade,
|
|
State(state): State<Arc<GatewayState>>,
|
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
|
// Validate Origin header to prevent cross-site WebSocket hijacking.
|
|
// Require the header outright; browsers always send it for WS upgrades,
|
|
// so a missing Origin means a non-browser client trying to bypass the check.
|
|
let origin = headers
|
|
.get("origin")
|
|
.and_then(|v| v.to_str().ok())
|
|
.ok_or_else(|| {
|
|
(
|
|
StatusCode::FORBIDDEN,
|
|
"WebSocket Origin header required".to_string(),
|
|
)
|
|
})?;
|
|
|
|
// Extract the host from the origin and compare exactly, so that
|
|
// crafted origins like "http://localhost.evil.com" are rejected.
|
|
// Origin format is "scheme://host[:port]".
|
|
let host = origin
|
|
.strip_prefix("http://")
|
|
.or_else(|| origin.strip_prefix("https://"))
|
|
.and_then(|rest| rest.split(':').next()?.split('/').next())
|
|
.unwrap_or("");
|
|
|
|
let is_local = matches!(host, "localhost" | "127.0.0.1" | "[::1]");
|
|
if !is_local {
|
|
return Err((
|
|
StatusCode::FORBIDDEN,
|
|
"WebSocket origin not allowed".to_string(),
|
|
));
|
|
}
|
|
Ok(ws.on_upgrade(move |socket| crate::channels::web::ws::handle_ws_connection(socket, state)))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct HistoryQuery {
|
|
thread_id: Option<String>,
|
|
limit: Option<usize>,
|
|
before: Option<String>,
|
|
}
|
|
|
|
async fn chat_history_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Query(query): Query<HistoryQuery>,
|
|
) -> Result<Json<HistoryResponse>, (StatusCode, String)> {
|
|
let session_manager = state.session_manager.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Session manager not available".to_string(),
|
|
))?;
|
|
|
|
let session = session_manager.get_or_create_session(&state.user_id).await;
|
|
let sess = session.lock().await;
|
|
|
|
let limit = query.limit.unwrap_or(50);
|
|
let before_cursor = query
|
|
.before
|
|
.as_deref()
|
|
.map(|s| {
|
|
chrono::DateTime::parse_from_rfc3339(s)
|
|
.map(|dt| dt.with_timezone(&chrono::Utc))
|
|
.map_err(|_| {
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
"Invalid 'before' timestamp".to_string(),
|
|
)
|
|
})
|
|
})
|
|
.transpose()?;
|
|
|
|
// Find the thread
|
|
let thread_id = if let Some(ref tid) = query.thread_id {
|
|
Uuid::parse_str(tid)
|
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid thread_id".to_string()))?
|
|
} else {
|
|
sess.active_thread
|
|
.ok_or((StatusCode::NOT_FOUND, "No active thread".to_string()))?
|
|
};
|
|
|
|
// Verify the thread belongs to the authenticated user before returning any data.
|
|
// In-memory threads are already scoped by user via session_manager, but DB
|
|
// lookups could expose another user's conversation if the UUID is guessed.
|
|
if query.thread_id.is_some()
|
|
&& let Some(ref store) = state.store
|
|
{
|
|
let owned = store
|
|
.conversation_belongs_to_user(thread_id, &state.user_id)
|
|
.await
|
|
.unwrap_or(false);
|
|
if !owned && !sess.threads.contains_key(&thread_id) {
|
|
return Err((StatusCode::NOT_FOUND, "Thread not found".to_string()));
|
|
}
|
|
}
|
|
|
|
// For paginated requests (before cursor set), always go to DB
|
|
if before_cursor.is_some()
|
|
&& let Some(ref store) = state.store
|
|
{
|
|
let (messages, has_more) = store
|
|
.list_conversation_messages_paginated(thread_id, before_cursor, limit as i64)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339());
|
|
let turns = build_turns_from_db_messages(&messages);
|
|
return Ok(Json(HistoryResponse {
|
|
thread_id,
|
|
turns,
|
|
has_more,
|
|
oldest_timestamp,
|
|
pending_approval: None,
|
|
}));
|
|
}
|
|
|
|
// Try in-memory first (freshest data for active threads)
|
|
if let Some(thread) = sess.threads.get(&thread_id)
|
|
&& (!thread.turns.is_empty() || thread.pending_approval.is_some())
|
|
{
|
|
let turns: Vec<TurnInfo> = thread
|
|
.turns
|
|
.iter()
|
|
.map(|t| TurnInfo {
|
|
turn_number: t.turn_number,
|
|
user_input: t.user_input.clone(),
|
|
response: t.response.clone(),
|
|
state: format!("{:?}", t.state),
|
|
started_at: t.started_at.to_rfc3339(),
|
|
completed_at: t.completed_at.map(|dt| dt.to_rfc3339()),
|
|
tool_calls: t
|
|
.tool_calls
|
|
.iter()
|
|
.map(|tc| ToolCallInfo {
|
|
name: tc.name.clone(),
|
|
has_result: tc.result.is_some(),
|
|
has_error: tc.error.is_some(),
|
|
result_preview: tc.result.as_ref().map(|r| {
|
|
let s = match r {
|
|
serde_json::Value::String(s) => s.clone(),
|
|
other => other.to_string(),
|
|
};
|
|
truncate_preview(&s, 500)
|
|
}),
|
|
error: tc.error.clone(),
|
|
})
|
|
.collect(),
|
|
})
|
|
.collect();
|
|
|
|
let pending_approval = thread
|
|
.pending_approval
|
|
.as_ref()
|
|
.map(|pa| PendingApprovalInfo {
|
|
request_id: pa.request_id.to_string(),
|
|
tool_name: pa.tool_name.clone(),
|
|
description: pa.description.clone(),
|
|
parameters: serde_json::to_string_pretty(&pa.parameters).unwrap_or_default(),
|
|
});
|
|
|
|
return Ok(Json(HistoryResponse {
|
|
thread_id,
|
|
turns,
|
|
has_more: false,
|
|
oldest_timestamp: None,
|
|
pending_approval,
|
|
}));
|
|
}
|
|
|
|
// Fall back to DB for historical threads not in memory (paginated)
|
|
if let Some(ref store) = state.store {
|
|
let (messages, has_more) = store
|
|
.list_conversation_messages_paginated(thread_id, None, limit as i64)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
if !messages.is_empty() {
|
|
let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339());
|
|
let turns = build_turns_from_db_messages(&messages);
|
|
return Ok(Json(HistoryResponse {
|
|
thread_id,
|
|
turns,
|
|
has_more,
|
|
oldest_timestamp,
|
|
pending_approval: None,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Empty thread (just created, no messages yet)
|
|
Ok(Json(HistoryResponse {
|
|
thread_id,
|
|
turns: Vec::new(),
|
|
has_more: false,
|
|
oldest_timestamp: None,
|
|
pending_approval: None,
|
|
}))
|
|
}
|
|
|
|
async fn chat_threads_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
) -> Result<Json<ThreadListResponse>, (StatusCode, String)> {
|
|
let session_manager = state.session_manager.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Session manager not available".to_string(),
|
|
))?;
|
|
|
|
let session = session_manager.get_or_create_session(&state.user_id).await;
|
|
let sess = session.lock().await;
|
|
|
|
// Try DB first for persistent thread list
|
|
if let Some(ref store) = state.store {
|
|
// Auto-create assistant thread if it doesn't exist
|
|
let assistant_id = store
|
|
.get_or_create_assistant_conversation(&state.user_id, "gateway")
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
if let Ok(summaries) = store
|
|
.list_conversations_all_channels(&state.user_id, 50)
|
|
.await
|
|
{
|
|
let mut assistant_thread = None;
|
|
let mut threads = Vec::new();
|
|
|
|
for s in &summaries {
|
|
let info = ThreadInfo {
|
|
id: s.id,
|
|
state: "Idle".to_string(),
|
|
turn_count: s.message_count.max(0) as usize,
|
|
created_at: s.started_at.to_rfc3339(),
|
|
updated_at: s.last_activity.to_rfc3339(),
|
|
title: s.title.clone(),
|
|
thread_type: s.thread_type.clone(),
|
|
channel: Some(s.channel.clone()),
|
|
};
|
|
|
|
if s.id == assistant_id {
|
|
assistant_thread = Some(info);
|
|
} else {
|
|
threads.push(info);
|
|
}
|
|
}
|
|
|
|
// If assistant wasn't in the list (0 messages), synthesize it
|
|
if assistant_thread.is_none() {
|
|
assistant_thread = Some(ThreadInfo {
|
|
id: assistant_id,
|
|
state: "Idle".to_string(),
|
|
turn_count: 0,
|
|
created_at: chrono::Utc::now().to_rfc3339(),
|
|
updated_at: chrono::Utc::now().to_rfc3339(),
|
|
title: None,
|
|
thread_type: Some("assistant".to_string()),
|
|
channel: Some("gateway".to_string()),
|
|
});
|
|
}
|
|
|
|
return Ok(Json(ThreadListResponse {
|
|
assistant_thread,
|
|
threads,
|
|
active_thread: sess.active_thread,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Fallback: in-memory only (no assistant thread without DB)
|
|
let mut sorted_threads: Vec<_> = sess.threads.values().collect();
|
|
sorted_threads.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
|
let threads: Vec<ThreadInfo> = sorted_threads
|
|
.into_iter()
|
|
.map(|t| ThreadInfo {
|
|
id: t.id,
|
|
state: format!("{:?}", t.state),
|
|
turn_count: t.turns.len(),
|
|
created_at: t.created_at.to_rfc3339(),
|
|
updated_at: t.updated_at.to_rfc3339(),
|
|
title: None,
|
|
thread_type: None,
|
|
channel: Some("gateway".to_string()),
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(ThreadListResponse {
|
|
assistant_thread: None,
|
|
threads,
|
|
active_thread: sess.active_thread,
|
|
}))
|
|
}
|
|
|
|
async fn chat_new_thread_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
) -> Result<Json<ThreadInfo>, (StatusCode, String)> {
|
|
let session_manager = state.session_manager.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Session manager not available".to_string(),
|
|
))?;
|
|
|
|
let session = session_manager.get_or_create_session(&state.user_id).await;
|
|
let (thread_id, info) = {
|
|
let mut sess = session.lock().await;
|
|
let thread = sess.create_thread();
|
|
let id = thread.id;
|
|
let info = ThreadInfo {
|
|
id: thread.id,
|
|
state: format!("{:?}", thread.state),
|
|
turn_count: thread.turns.len(),
|
|
created_at: thread.created_at.to_rfc3339(),
|
|
updated_at: thread.updated_at.to_rfc3339(),
|
|
title: None,
|
|
thread_type: Some("thread".to_string()),
|
|
channel: Some("gateway".to_string()),
|
|
};
|
|
(id, info)
|
|
};
|
|
|
|
// Persist the empty conversation row with thread_type metadata synchronously
|
|
// so that the subsequent loadThreads() call from the frontend sees it.
|
|
if let Some(ref store) = state.store {
|
|
if let Err(e) = store
|
|
.ensure_conversation(thread_id, "gateway", &state.user_id, None)
|
|
.await
|
|
{
|
|
tracing::warn!("Failed to persist new thread: {}", e);
|
|
}
|
|
let metadata_val = serde_json::json!("thread");
|
|
if let Err(e) = store
|
|
.update_conversation_metadata_field(thread_id, "thread_type", &metadata_val)
|
|
.await
|
|
{
|
|
tracing::warn!("Failed to set thread_type metadata: {}", e);
|
|
}
|
|
}
|
|
|
|
Ok(Json(info))
|
|
}
|
|
|
|
// --- Memory handlers ---
|
|
|
|
#[derive(Deserialize)]
|
|
struct TreeQuery {
|
|
#[allow(dead_code)]
|
|
depth: Option<usize>,
|
|
}
|
|
|
|
async fn memory_tree_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Query(_query): Query<TreeQuery>,
|
|
) -> Result<Json<MemoryTreeResponse>, (StatusCode, String)> {
|
|
let workspace = state.workspace.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Workspace not available".to_string(),
|
|
))?;
|
|
|
|
// Build tree from list_all (flat list of all paths)
|
|
let all_paths = workspace
|
|
.list_all()
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
// Collect unique directories and files
|
|
let mut entries: Vec<TreeEntry> = Vec::new();
|
|
let mut seen_dirs: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
|
|
for path in &all_paths {
|
|
// Add parent directories
|
|
let parts: Vec<&str> = path.split('/').collect();
|
|
for i in 0..parts.len().saturating_sub(1) {
|
|
let dir_path = parts[..=i].join("/");
|
|
if seen_dirs.insert(dir_path.clone()) {
|
|
entries.push(TreeEntry {
|
|
path: dir_path,
|
|
is_dir: true,
|
|
});
|
|
}
|
|
}
|
|
// Add the file itself
|
|
entries.push(TreeEntry {
|
|
path: path.clone(),
|
|
is_dir: false,
|
|
});
|
|
}
|
|
|
|
entries.sort_by(|a, b| a.path.cmp(&b.path));
|
|
|
|
Ok(Json(MemoryTreeResponse { entries }))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ListQuery {
|
|
path: Option<String>,
|
|
}
|
|
|
|
async fn memory_list_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Query(query): Query<ListQuery>,
|
|
) -> Result<Json<MemoryListResponse>, (StatusCode, String)> {
|
|
let workspace = state.workspace.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Workspace not available".to_string(),
|
|
))?;
|
|
|
|
let path = query.path.as_deref().unwrap_or("");
|
|
let entries = workspace
|
|
.list(path)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let list_entries: Vec<ListEntry> = entries
|
|
.iter()
|
|
.map(|e| ListEntry {
|
|
name: e.path.rsplit('/').next().unwrap_or(&e.path).to_string(),
|
|
path: e.path.clone(),
|
|
is_dir: e.is_directory,
|
|
updated_at: e.updated_at.map(|dt| dt.to_rfc3339()),
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(MemoryListResponse {
|
|
path: path.to_string(),
|
|
entries: list_entries,
|
|
}))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ReadQuery {
|
|
path: String,
|
|
}
|
|
|
|
async fn memory_read_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Query(query): Query<ReadQuery>,
|
|
) -> Result<Json<MemoryReadResponse>, (StatusCode, String)> {
|
|
let workspace = state.workspace.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Workspace not available".to_string(),
|
|
))?;
|
|
|
|
let doc = workspace
|
|
.read(&query.path)
|
|
.await
|
|
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
|
|
|
Ok(Json(MemoryReadResponse {
|
|
path: query.path,
|
|
content: doc.content,
|
|
updated_at: Some(doc.updated_at.to_rfc3339()),
|
|
}))
|
|
}
|
|
|
|
async fn memory_write_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Json(req): Json<MemoryWriteRequest>,
|
|
) -> Result<Json<MemoryWriteResponse>, (StatusCode, String)> {
|
|
let workspace = state.workspace.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Workspace not available".to_string(),
|
|
))?;
|
|
|
|
workspace
|
|
.write(&req.path, &req.content)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
Ok(Json(MemoryWriteResponse {
|
|
path: req.path,
|
|
status: "written",
|
|
}))
|
|
}
|
|
|
|
async fn memory_search_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Json(req): Json<MemorySearchRequest>,
|
|
) -> Result<Json<MemorySearchResponse>, (StatusCode, String)> {
|
|
let workspace = state.workspace.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Workspace not available".to_string(),
|
|
))?;
|
|
|
|
let limit = req.limit.unwrap_or(10);
|
|
let results = workspace
|
|
.search(&req.query, limit)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let hits: Vec<SearchHit> = results
|
|
.iter()
|
|
.map(|r| SearchHit {
|
|
path: r.document_id.to_string(),
|
|
content: r.content.clone(),
|
|
score: r.score as f64,
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(MemorySearchResponse { results: hits }))
|
|
}
|
|
|
|
// Job handlers moved to handlers/jobs.rs
|
|
// --- Logs handlers ---
|
|
|
|
async fn logs_events_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
|
let broadcaster = state.log_broadcaster.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Log broadcaster not available".to_string(),
|
|
))?;
|
|
|
|
// Replay recent history so late-joining browsers see startup logs.
|
|
// Subscribe BEFORE snapshotting to avoid a gap between history and live.
|
|
let rx = broadcaster.subscribe();
|
|
let history = broadcaster.recent_entries();
|
|
|
|
let history_stream = futures::stream::iter(history).map(|entry| {
|
|
let data = serde_json::to_string(&entry).unwrap_or_default();
|
|
Ok::<_, Infallible>(Event::default().event("log").data(data))
|
|
});
|
|
|
|
let live_stream = tokio_stream::wrappers::BroadcastStream::new(rx)
|
|
.filter_map(|result| result.ok())
|
|
.map(|entry| {
|
|
let data = serde_json::to_string(&entry).unwrap_or_default();
|
|
Ok::<_, Infallible>(Event::default().event("log").data(data))
|
|
});
|
|
|
|
let stream = history_stream.chain(live_stream);
|
|
|
|
Ok((
|
|
[("X-Accel-Buffering", "no"), ("Cache-Control", "no-cache")],
|
|
Sse::new(stream).keep_alive(
|
|
KeepAlive::new()
|
|
.interval(std::time::Duration::from_secs(30))
|
|
.text(""),
|
|
),
|
|
))
|
|
}
|
|
|
|
async fn logs_level_get_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let handle = state.log_level_handle.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Log level control not available".to_string(),
|
|
))?;
|
|
Ok(Json(serde_json::json!({ "level": handle.current_level() })))
|
|
}
|
|
|
|
async fn logs_level_set_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Json(body): Json<serde_json::Value>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let handle = state.log_level_handle.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Log level control not available".to_string(),
|
|
))?;
|
|
|
|
let level = body
|
|
.get("level")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or((StatusCode::BAD_REQUEST, "missing 'level' field".to_string()))?;
|
|
|
|
handle
|
|
.set_level(level)
|
|
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
|
|
|
tracing::info!("Log level changed to '{}'", handle.current_level());
|
|
Ok(Json(serde_json::json!({ "level": handle.current_level() })))
|
|
}
|
|
|
|
// --- Extension handlers ---
|
|
|
|
async fn extensions_list_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
) -> Result<Json<ExtensionListResponse>, (StatusCode, String)> {
|
|
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
|
StatusCode::NOT_IMPLEMENTED,
|
|
"Extension manager not available (secrets store required)".to_string(),
|
|
))?;
|
|
|
|
let installed = ext_mgr
|
|
.list(None, false)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let pairing_store = crate::pairing::PairingStore::new();
|
|
let extensions = installed
|
|
.into_iter()
|
|
.map(|ext| {
|
|
let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel {
|
|
Some(if ext.activation_error.is_some() {
|
|
"failed".to_string()
|
|
} else if !ext.authenticated {
|
|
// No credentials configured yet.
|
|
"installed".to_string()
|
|
} else if ext.active {
|
|
// Check pairing status for active channels.
|
|
let has_paired = pairing_store
|
|
.read_allow_from(&ext.name)
|
|
.map(|list| !list.is_empty())
|
|
.unwrap_or(false);
|
|
if has_paired {
|
|
"active".to_string()
|
|
} else {
|
|
"pairing".to_string()
|
|
}
|
|
} else {
|
|
// Authenticated but not yet active.
|
|
"configured".to_string()
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
ExtensionInfo {
|
|
name: ext.name,
|
|
display_name: ext.display_name,
|
|
kind: ext.kind.to_string(),
|
|
description: ext.description,
|
|
url: ext.url,
|
|
authenticated: ext.authenticated,
|
|
active: ext.active,
|
|
tools: ext.tools,
|
|
needs_setup: ext.needs_setup,
|
|
has_auth: ext.has_auth,
|
|
activation_status,
|
|
activation_error: ext.activation_error,
|
|
version: ext.version,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(ExtensionListResponse { extensions }))
|
|
}
|
|
|
|
async fn extensions_tools_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
) -> Result<Json<ToolListResponse>, (StatusCode, String)> {
|
|
let registry = state.tool_registry.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Tool registry not available".to_string(),
|
|
))?;
|
|
|
|
let definitions = registry.tool_definitions().await;
|
|
let tools = definitions
|
|
.into_iter()
|
|
.map(|td| ToolInfo {
|
|
name: td.name,
|
|
description: td.description,
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(ToolListResponse { tools }))
|
|
}
|
|
|
|
async fn extensions_install_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Json(req): Json<InstallExtensionRequest>,
|
|
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
|
// When extension manager isn't available, check registry entries for a helpful message
|
|
let Some(ext_mgr) = state.extension_manager.as_ref() else {
|
|
// Look up the entry in the catalog to give a specific error
|
|
if let Some(entry) = state.registry_entries.iter().find(|e| e.name == req.name) {
|
|
let msg = match &entry.source {
|
|
crate::extensions::ExtensionSource::WasmBuildable { .. } => {
|
|
format!(
|
|
"'{}' requires building from source. \
|
|
Run `ironclaw registry install {}` from the CLI.",
|
|
req.name, req.name
|
|
)
|
|
}
|
|
_ => format!(
|
|
"Extension manager not available (secrets store required). \
|
|
Configure DATABASE_URL or a secrets backend to enable installation of '{}'.",
|
|
req.name
|
|
),
|
|
};
|
|
return Ok(Json(ActionResponse::fail(msg)));
|
|
}
|
|
return Ok(Json(ActionResponse::fail(
|
|
"Extension manager not available (secrets store required)".to_string(),
|
|
)));
|
|
};
|
|
|
|
let kind_hint = req.kind.as_deref().and_then(|k| match k {
|
|
"mcp_server" => Some(crate::extensions::ExtensionKind::McpServer),
|
|
"wasm_tool" => Some(crate::extensions::ExtensionKind::WasmTool),
|
|
"wasm_channel" => Some(crate::extensions::ExtensionKind::WasmChannel),
|
|
_ => None,
|
|
});
|
|
|
|
match ext_mgr
|
|
.install(&req.name, req.url.as_deref(), kind_hint)
|
|
.await
|
|
{
|
|
Ok(result) => {
|
|
let mut resp = ActionResponse::ok(result.message);
|
|
|
|
// Auto-activate WASM tools after install (install = active).
|
|
if result.kind == crate::extensions::ExtensionKind::WasmTool {
|
|
if let Err(e) = ext_mgr.activate(&req.name).await {
|
|
tracing::debug!(
|
|
extension = %req.name,
|
|
error = %e,
|
|
"Auto-activation after install failed"
|
|
);
|
|
}
|
|
|
|
// Check auth after activation. This may initiate OAuth both for scope
|
|
// expansion and for first-time auth when credentials are already
|
|
// configured (e.g., built-in providers). We only surface an auth_url
|
|
// when the extension reports it is awaiting authorization.
|
|
match ext_mgr.auth(&req.name).await {
|
|
Ok(auth_result) if auth_result.auth_url().is_some() => {
|
|
// Scope expansion or initial OAuth: user needs to authorize
|
|
resp.auth_url = auth_result.auth_url().map(String::from);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
Ok(Json(resp))
|
|
}
|
|
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
|
}
|
|
}
|
|
|
|
async fn extensions_activate_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Path(name): Path<String>,
|
|
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
|
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
|
StatusCode::NOT_IMPLEMENTED,
|
|
"Extension manager not available (secrets store required)".to_string(),
|
|
))?;
|
|
|
|
match ext_mgr.activate(&name).await {
|
|
Ok(result) => {
|
|
// Activation loaded the WASM module. Check if the tool needs
|
|
// OAuth scope expansion (e.g., adding google-docs when gmail
|
|
// already has a token but missing the documents scope).
|
|
// Initial OAuth setup is triggered via configure.
|
|
let mut resp = ActionResponse::ok(result.message);
|
|
if let Ok(auth_result) = ext_mgr.auth(&name).await
|
|
&& auth_result.auth_url().is_some()
|
|
{
|
|
resp.auth_url = auth_result.auth_url().map(String::from);
|
|
}
|
|
Ok(Json(resp))
|
|
}
|
|
Err(activate_err) => {
|
|
let needs_auth = matches!(
|
|
&activate_err,
|
|
crate::extensions::ExtensionError::AuthRequired
|
|
);
|
|
|
|
if !needs_auth {
|
|
return Ok(Json(ActionResponse::fail(activate_err.to_string())));
|
|
}
|
|
|
|
// Activation failed due to auth; try authenticating first.
|
|
match ext_mgr.auth(&name).await {
|
|
Ok(auth_result) if auth_result.is_authenticated() => {
|
|
// Auth succeeded, retry activation.
|
|
match ext_mgr.activate(&name).await {
|
|
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
|
|
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
|
}
|
|
}
|
|
Ok(auth_result) => {
|
|
// Auth in progress (OAuth URL or awaiting manual token).
|
|
let mut resp = ActionResponse::fail(
|
|
auth_result
|
|
.instructions()
|
|
.map(String::from)
|
|
.unwrap_or_else(|| format!("'{}' requires authentication.", name)),
|
|
);
|
|
resp.auth_url = auth_result.auth_url().map(String::from);
|
|
resp.awaiting_token = Some(auth_result.is_awaiting_token());
|
|
resp.instructions = auth_result.instructions().map(String::from);
|
|
Ok(Json(resp))
|
|
}
|
|
Err(auth_err) => Ok(Json(ActionResponse::fail(format!(
|
|
"Authentication failed: {}",
|
|
auth_err
|
|
)))),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Project file serving handlers ---
|
|
|
|
/// Redirect `/projects/{id}` to `/projects/{id}/` so relative paths in
|
|
/// the served HTML resolve within the project namespace.
|
|
async fn project_redirect_handler(Path(project_id): Path<String>) -> impl IntoResponse {
|
|
axum::response::Redirect::permanent(&format!("/projects/{project_id}/"))
|
|
}
|
|
|
|
/// Serve `index.html` when hitting `/projects/{project_id}/`.
|
|
async fn project_index_handler(Path(project_id): Path<String>) -> impl IntoResponse {
|
|
serve_project_file(&project_id, "index.html").await
|
|
}
|
|
|
|
/// Serve any file under `/projects/{project_id}/{path}`.
|
|
async fn project_file_handler(
|
|
Path((project_id, path)): Path<(String, String)>,
|
|
) -> impl IntoResponse {
|
|
serve_project_file(&project_id, &path).await
|
|
}
|
|
|
|
/// Shared logic: resolve the file inside `~/.ironclaw/projects/{project_id}/`,
|
|
/// guard against path traversal, and stream the content with the right MIME type.
|
|
async fn serve_project_file(project_id: &str, path: &str) -> axum::response::Response {
|
|
// Reject project_id values that could escape the projects directory.
|
|
if project_id.contains('/')
|
|
|| project_id.contains('\\')
|
|
|| project_id.contains("..")
|
|
|| project_id.is_empty()
|
|
{
|
|
return (StatusCode::BAD_REQUEST, "Invalid project ID").into_response();
|
|
}
|
|
|
|
let base = ironclaw_base_dir().join("projects").join(project_id);
|
|
|
|
let file_path = base.join(path);
|
|
|
|
// Path traversal guard
|
|
let canonical = match file_path.canonicalize() {
|
|
Ok(p) => p,
|
|
Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(),
|
|
};
|
|
let base_canonical = match base.canonicalize() {
|
|
Ok(p) => p,
|
|
Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(),
|
|
};
|
|
if !canonical.starts_with(&base_canonical) {
|
|
return (StatusCode::FORBIDDEN, "Forbidden").into_response();
|
|
}
|
|
|
|
match tokio::fs::read(&canonical).await {
|
|
Ok(contents) => {
|
|
let mime = mime_guess::from_path(&canonical)
|
|
.first_or_octet_stream()
|
|
.to_string();
|
|
([(header::CONTENT_TYPE, mime)], contents).into_response()
|
|
}
|
|
Err(_) => (StatusCode::NOT_FOUND, "Not found").into_response(),
|
|
}
|
|
}
|
|
|
|
async fn extensions_remove_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Path(name): Path<String>,
|
|
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
|
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
|
StatusCode::NOT_IMPLEMENTED,
|
|
"Extension manager not available (secrets store required)".to_string(),
|
|
))?;
|
|
|
|
match ext_mgr.remove(&name).await {
|
|
Ok(message) => Ok(Json(ActionResponse::ok(message))),
|
|
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
|
}
|
|
}
|
|
|
|
async fn extensions_registry_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Query(params): Query<RegistrySearchQuery>,
|
|
) -> Json<RegistrySearchResponse> {
|
|
let query = params.query.unwrap_or_default();
|
|
let query_lower = query.to_lowercase();
|
|
let tokens: Vec<&str> = query_lower.split_whitespace().collect();
|
|
|
|
// Filter registry entries by query (or return all if empty)
|
|
let matching: Vec<&crate::extensions::RegistryEntry> = if tokens.is_empty() {
|
|
state.registry_entries.iter().collect()
|
|
} else {
|
|
state
|
|
.registry_entries
|
|
.iter()
|
|
.filter(|e| {
|
|
let name = e.name.to_lowercase();
|
|
let display = e.display_name.to_lowercase();
|
|
let desc = e.description.to_lowercase();
|
|
tokens.iter().any(|t| {
|
|
name.contains(t)
|
|
|| display.contains(t)
|
|
|| desc.contains(t)
|
|
|| e.keywords.iter().any(|k| k.to_lowercase().contains(t))
|
|
})
|
|
})
|
|
.collect()
|
|
};
|
|
|
|
// Cross-reference with installed extensions by (name, kind) to avoid
|
|
// false positives when the same name exists as different kinds.
|
|
let installed: std::collections::HashSet<(String, String)> =
|
|
if let Some(ext_mgr) = state.extension_manager.as_ref() {
|
|
ext_mgr
|
|
.list(None, false)
|
|
.await
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|ext| (ext.name, ext.kind.to_string()))
|
|
.collect()
|
|
} else {
|
|
std::collections::HashSet::new()
|
|
};
|
|
|
|
let entries = matching
|
|
.into_iter()
|
|
.map(|e| {
|
|
let kind_str = e.kind.to_string();
|
|
RegistryEntryInfo {
|
|
name: e.name.clone(),
|
|
display_name: e.display_name.clone(),
|
|
installed: installed.contains(&(e.name.clone(), kind_str.clone())),
|
|
kind: kind_str,
|
|
description: e.description.clone(),
|
|
keywords: e.keywords.clone(),
|
|
version: e.version.clone(),
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Json(RegistrySearchResponse { entries })
|
|
}
|
|
|
|
async fn extensions_setup_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Path(name): Path<String>,
|
|
) -> Result<Json<ExtensionSetupResponse>, (StatusCode, String)> {
|
|
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
|
StatusCode::NOT_IMPLEMENTED,
|
|
"Extension manager not available (secrets store required)".to_string(),
|
|
))?;
|
|
|
|
let secrets = ext_mgr
|
|
.get_setup_schema(&name)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let kind = ext_mgr
|
|
.list(None, false)
|
|
.await
|
|
.ok()
|
|
.and_then(|list| list.into_iter().find(|e| e.name == name))
|
|
.map(|e| e.kind.to_string())
|
|
.unwrap_or_default();
|
|
|
|
Ok(Json(ExtensionSetupResponse {
|
|
name,
|
|
kind,
|
|
secrets,
|
|
}))
|
|
}
|
|
|
|
async fn extensions_setup_submit_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Path(name): Path<String>,
|
|
Json(req): Json<ExtensionSetupRequest>,
|
|
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
|
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
|
StatusCode::NOT_IMPLEMENTED,
|
|
"Extension manager not available (secrets store required)".to_string(),
|
|
))?;
|
|
|
|
match ext_mgr.configure(&name, &req.secrets).await {
|
|
Ok(result) => {
|
|
// Broadcast auth_completed so the chat UI can dismiss any in-progress
|
|
// auth card or setup modal that was triggered by tool_auth/tool_activate.
|
|
state.sse.broadcast(SseEvent::AuthCompleted {
|
|
extension_name: name.clone(),
|
|
success: true,
|
|
message: result.message.clone(),
|
|
});
|
|
let mut resp = ActionResponse::ok(result.message);
|
|
resp.activated = Some(result.activated);
|
|
resp.auth_url = result.auth_url;
|
|
Ok(Json(resp))
|
|
}
|
|
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
|
}
|
|
}
|
|
|
|
// --- Pairing handlers ---
|
|
|
|
async fn pairing_list_handler(
|
|
Path(channel): Path<String>,
|
|
) -> Result<Json<PairingListResponse>, (StatusCode, String)> {
|
|
let store = crate::pairing::PairingStore::new();
|
|
let requests = store
|
|
.list_pending(&channel)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let infos = requests
|
|
.into_iter()
|
|
.map(|r| PairingRequestInfo {
|
|
code: r.code,
|
|
sender_id: r.id,
|
|
meta: r.meta,
|
|
created_at: r.created_at,
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(PairingListResponse {
|
|
channel,
|
|
requests: infos,
|
|
}))
|
|
}
|
|
|
|
async fn pairing_approve_handler(
|
|
Path(channel): Path<String>,
|
|
Json(req): Json<PairingApproveRequest>,
|
|
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
|
let store = crate::pairing::PairingStore::new();
|
|
match store.approve(&channel, &req.code) {
|
|
Ok(Some(approved)) => Ok(Json(ActionResponse::ok(format!(
|
|
"Pairing approved for sender '{}'",
|
|
approved.id
|
|
)))),
|
|
Ok(None) => Ok(Json(ActionResponse::fail(
|
|
"Invalid or expired pairing code".to_string(),
|
|
))),
|
|
Err(crate::pairing::PairingStoreError::ApproveRateLimited) => Err((
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
"Too many failed approve attempts; try again later".to_string(),
|
|
)),
|
|
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
|
}
|
|
}
|
|
|
|
// --- Routines handlers ---
|
|
|
|
async fn routines_list_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
) -> Result<Json<RoutineListResponse>, (StatusCode, String)> {
|
|
let store = state.store.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Database not available".to_string(),
|
|
))?;
|
|
|
|
let routines = store
|
|
.list_all_routines()
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let items: Vec<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect();
|
|
|
|
Ok(Json(RoutineListResponse { routines: items }))
|
|
}
|
|
|
|
async fn routines_summary_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
) -> Result<Json<RoutineSummaryResponse>, (StatusCode, String)> {
|
|
let store = state.store.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Database not available".to_string(),
|
|
))?;
|
|
|
|
let routines = store
|
|
.list_all_routines()
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let total = routines.len() as u64;
|
|
let enabled = routines.iter().filter(|r| r.enabled).count() as u64;
|
|
let disabled = total - enabled;
|
|
let failing = routines
|
|
.iter()
|
|
.filter(|r| r.consecutive_failures > 0)
|
|
.count() as u64;
|
|
|
|
let today_start = chrono::Utc::now()
|
|
.date_naive()
|
|
.and_hms_opt(0, 0, 0)
|
|
.map(|dt| dt.and_utc());
|
|
let runs_today = if let Some(start) = today_start {
|
|
routines
|
|
.iter()
|
|
.filter(|r| r.last_run_at.is_some_and(|ts| ts >= start))
|
|
.count() as u64
|
|
} else {
|
|
0
|
|
};
|
|
|
|
Ok(Json(RoutineSummaryResponse {
|
|
total,
|
|
enabled,
|
|
disabled,
|
|
failing,
|
|
runs_today,
|
|
}))
|
|
}
|
|
|
|
async fn routines_detail_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<RoutineDetailResponse>, (StatusCode, String)> {
|
|
let store = state.store.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Database not available".to_string(),
|
|
))?;
|
|
|
|
let routine_id = Uuid::parse_str(&id)
|
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
|
|
|
|
let routine = store
|
|
.get_routine(routine_id)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
|
|
|
|
let runs = store
|
|
.list_routine_runs(routine_id, 20)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let recent_runs: Vec<RoutineRunInfo> = runs
|
|
.iter()
|
|
.map(|run| RoutineRunInfo {
|
|
id: run.id,
|
|
trigger_type: run.trigger_type.clone(),
|
|
started_at: run.started_at.to_rfc3339(),
|
|
completed_at: run.completed_at.map(|dt| dt.to_rfc3339()),
|
|
status: format!("{:?}", run.status),
|
|
result_summary: run.result_summary.clone(),
|
|
tokens_used: run.tokens_used,
|
|
job_id: run.job_id,
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(RoutineDetailResponse {
|
|
id: routine.id,
|
|
name: routine.name.clone(),
|
|
description: routine.description.clone(),
|
|
enabled: routine.enabled,
|
|
trigger: serde_json::to_value(&routine.trigger).unwrap_or_default(),
|
|
action: serde_json::to_value(&routine.action).unwrap_or_default(),
|
|
guardrails: serde_json::to_value(&routine.guardrails).unwrap_or_default(),
|
|
notify: serde_json::to_value(&routine.notify).unwrap_or_default(),
|
|
last_run_at: routine.last_run_at.map(|dt| dt.to_rfc3339()),
|
|
next_fire_at: routine.next_fire_at.map(|dt| dt.to_rfc3339()),
|
|
run_count: routine.run_count,
|
|
consecutive_failures: routine.consecutive_failures,
|
|
created_at: routine.created_at.to_rfc3339(),
|
|
recent_runs,
|
|
}))
|
|
}
|
|
|
|
async fn routines_trigger_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let engine = {
|
|
let guard = state.routine_engine.read().await;
|
|
guard.as_ref().cloned().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Routine engine not available".to_string(),
|
|
))?
|
|
};
|
|
|
|
let routine_id = Uuid::parse_str(&id)
|
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
|
|
|
|
let run_id = engine
|
|
.fire_manual(routine_id, Some(&state.user_id))
|
|
.await
|
|
.map_err(|e| {
|
|
let status = match &e {
|
|
crate::error::RoutineError::NotFound { .. } => StatusCode::NOT_FOUND,
|
|
crate::error::RoutineError::NotAuthorized { .. } => StatusCode::FORBIDDEN,
|
|
crate::error::RoutineError::Disabled { .. }
|
|
| crate::error::RoutineError::MaxConcurrent { .. } => StatusCode::CONFLICT,
|
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
|
};
|
|
(status, e.to_string())
|
|
})?;
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"status": "triggered",
|
|
"routine_id": routine_id,
|
|
"run_id": run_id,
|
|
})))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ToggleRequest {
|
|
enabled: Option<bool>,
|
|
}
|
|
|
|
async fn routines_toggle_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Path(id): Path<String>,
|
|
body: Option<Json<ToggleRequest>>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let store = state.store.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Database not available".to_string(),
|
|
))?;
|
|
|
|
let routine_id = Uuid::parse_str(&id)
|
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
|
|
|
|
let mut routine = store
|
|
.get_routine(routine_id)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
|
|
|
|
// If a specific value was provided, use it; otherwise toggle.
|
|
routine.enabled = match body {
|
|
Some(Json(req)) => req.enabled.unwrap_or(!routine.enabled),
|
|
None => !routine.enabled,
|
|
};
|
|
|
|
store
|
|
.update_routine(&routine)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"status": if routine.enabled { "enabled" } else { "disabled" },
|
|
"routine_id": routine_id,
|
|
})))
|
|
}
|
|
|
|
async fn routines_delete_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let store = state.store.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Database not available".to_string(),
|
|
))?;
|
|
|
|
let routine_id = Uuid::parse_str(&id)
|
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
|
|
|
|
let deleted = store
|
|
.delete_routine(routine_id)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
if deleted {
|
|
Ok(Json(serde_json::json!({
|
|
"status": "deleted",
|
|
"routine_id": routine_id,
|
|
})))
|
|
} else {
|
|
Err((StatusCode::NOT_FOUND, "Routine not found".to_string()))
|
|
}
|
|
}
|
|
|
|
async fn routines_runs_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let store = state.store.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Database not available".to_string(),
|
|
))?;
|
|
|
|
let routine_id = Uuid::parse_str(&id)
|
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
|
|
|
|
let runs = store
|
|
.list_routine_runs(routine_id, 50)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let run_infos: Vec<RoutineRunInfo> = runs
|
|
.iter()
|
|
.map(|run| RoutineRunInfo {
|
|
id: run.id,
|
|
trigger_type: run.trigger_type.clone(),
|
|
started_at: run.started_at.to_rfc3339(),
|
|
completed_at: run.completed_at.map(|dt| dt.to_rfc3339()),
|
|
status: format!("{:?}", run.status),
|
|
result_summary: run.result_summary.clone(),
|
|
tokens_used: run.tokens_used,
|
|
job_id: run.job_id,
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"routine_id": routine_id,
|
|
"runs": run_infos,
|
|
})))
|
|
}
|
|
|
|
// --- Settings handlers ---
|
|
|
|
async fn settings_list_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
) -> Result<Json<SettingsListResponse>, StatusCode> {
|
|
let store = state
|
|
.store
|
|
.as_ref()
|
|
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
|
let rows = store.list_settings(&state.user_id).await.map_err(|e| {
|
|
tracing::error!("Failed to list settings: {}", e);
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
let settings = rows
|
|
.into_iter()
|
|
.map(|r| SettingResponse {
|
|
key: r.key,
|
|
value: r.value,
|
|
updated_at: r.updated_at.to_rfc3339(),
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(SettingsListResponse { settings }))
|
|
}
|
|
|
|
async fn settings_get_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Path(key): Path<String>,
|
|
) -> Result<Json<SettingResponse>, StatusCode> {
|
|
let store = state
|
|
.store
|
|
.as_ref()
|
|
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
|
let row = store
|
|
.get_setting_full(&state.user_id, &key)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("Failed to get setting '{}': {}", key, e);
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?
|
|
.ok_or(StatusCode::NOT_FOUND)?;
|
|
|
|
Ok(Json(SettingResponse {
|
|
key: row.key,
|
|
value: row.value,
|
|
updated_at: row.updated_at.to_rfc3339(),
|
|
}))
|
|
}
|
|
|
|
async fn settings_set_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Path(key): Path<String>,
|
|
Json(body): Json<SettingWriteRequest>,
|
|
) -> Result<StatusCode, StatusCode> {
|
|
let store = state
|
|
.store
|
|
.as_ref()
|
|
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
|
store
|
|
.set_setting(&state.user_id, &key, &body.value)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("Failed to set setting '{}': {}", key, e);
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
async fn settings_delete_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Path(key): Path<String>,
|
|
) -> Result<StatusCode, StatusCode> {
|
|
let store = state
|
|
.store
|
|
.as_ref()
|
|
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
|
store
|
|
.delete_setting(&state.user_id, &key)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("Failed to delete setting '{}': {}", key, e);
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
async fn settings_export_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
) -> Result<Json<SettingsExportResponse>, StatusCode> {
|
|
let store = state
|
|
.store
|
|
.as_ref()
|
|
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
|
let settings = store.get_all_settings(&state.user_id).await.map_err(|e| {
|
|
tracing::error!("Failed to export settings: {}", e);
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
Ok(Json(SettingsExportResponse { settings }))
|
|
}
|
|
|
|
async fn settings_import_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
Json(body): Json<SettingsImportRequest>,
|
|
) -> Result<StatusCode, StatusCode> {
|
|
let store = state
|
|
.store
|
|
.as_ref()
|
|
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
|
store
|
|
.set_all_settings(&state.user_id, &body.settings)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("Failed to import settings: {}", e);
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
// --- Gateway control plane handlers ---
|
|
|
|
async fn gateway_status_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
) -> Json<GatewayStatusResponse> {
|
|
let sse_connections = state.sse.connection_count();
|
|
let ws_connections = state
|
|
.ws_tracker
|
|
.as_ref()
|
|
.map(|t| t.connection_count())
|
|
.unwrap_or(0);
|
|
|
|
let uptime_secs = state.startup_time.elapsed().as_secs();
|
|
|
|
let (daily_cost, actions_this_hour, model_usage) = if let Some(ref cg) = state.cost_guard {
|
|
let cost = cg.daily_spend().await;
|
|
let actions = cg.actions_this_hour().await;
|
|
let usage = cg.model_usage().await;
|
|
let models: Vec<ModelUsageEntry> = usage
|
|
.into_iter()
|
|
.map(|(model, tokens)| ModelUsageEntry {
|
|
model,
|
|
input_tokens: tokens.input_tokens,
|
|
output_tokens: tokens.output_tokens,
|
|
cost: format!("{:.6}", tokens.cost),
|
|
})
|
|
.collect();
|
|
(Some(format!("{:.4}", cost)), Some(actions), Some(models))
|
|
} else {
|
|
(None, None, None)
|
|
};
|
|
|
|
let restart_enabled = std::env::var("IRONCLAW_IN_DOCKER")
|
|
.map(|v| v.to_lowercase() == "true")
|
|
.unwrap_or(false);
|
|
|
|
Json(GatewayStatusResponse {
|
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
|
sse_connections,
|
|
ws_connections,
|
|
total_connections: sse_connections + ws_connections,
|
|
uptime_secs,
|
|
restart_enabled,
|
|
daily_cost,
|
|
actions_this_hour,
|
|
model_usage,
|
|
})
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
struct ModelUsageEntry {
|
|
model: String,
|
|
input_tokens: u64,
|
|
output_tokens: u64,
|
|
cost: String,
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
struct GatewayStatusResponse {
|
|
version: String,
|
|
sse_connections: u64,
|
|
ws_connections: u64,
|
|
total_connections: u64,
|
|
uptime_secs: u64,
|
|
restart_enabled: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
daily_cost: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
actions_this_hour: Option<u64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
model_usage: Option<Vec<ModelUsageEntry>>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::testing::credentials::TEST_GATEWAY_CRYPTO_KEY;
|
|
|
|
#[test]
|
|
fn test_build_turns_from_db_messages_complete() {
|
|
let now = chrono::Utc::now();
|
|
let messages = vec![
|
|
crate::history::ConversationMessage {
|
|
id: Uuid::new_v4(),
|
|
role: "user".to_string(),
|
|
content: "Hello".to_string(),
|
|
created_at: now,
|
|
},
|
|
crate::history::ConversationMessage {
|
|
id: Uuid::new_v4(),
|
|
role: "assistant".to_string(),
|
|
content: "Hi there!".to_string(),
|
|
created_at: now + chrono::TimeDelta::seconds(1),
|
|
},
|
|
crate::history::ConversationMessage {
|
|
id: Uuid::new_v4(),
|
|
role: "user".to_string(),
|
|
content: "How are you?".to_string(),
|
|
created_at: now + chrono::TimeDelta::seconds(2),
|
|
},
|
|
crate::history::ConversationMessage {
|
|
id: Uuid::new_v4(),
|
|
role: "assistant".to_string(),
|
|
content: "Doing well!".to_string(),
|
|
created_at: now + chrono::TimeDelta::seconds(3),
|
|
},
|
|
];
|
|
|
|
let turns = build_turns_from_db_messages(&messages);
|
|
assert_eq!(turns.len(), 2);
|
|
assert_eq!(turns[0].user_input, "Hello");
|
|
assert_eq!(turns[0].response.as_deref(), Some("Hi there!"));
|
|
assert_eq!(turns[0].state, "Completed");
|
|
assert_eq!(turns[1].user_input, "How are you?");
|
|
assert_eq!(turns[1].response.as_deref(), Some("Doing well!"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_turns_from_db_messages_incomplete_last() {
|
|
let now = chrono::Utc::now();
|
|
let messages = vec![
|
|
crate::history::ConversationMessage {
|
|
id: Uuid::new_v4(),
|
|
role: "user".to_string(),
|
|
content: "Hello".to_string(),
|
|
created_at: now,
|
|
},
|
|
crate::history::ConversationMessage {
|
|
id: Uuid::new_v4(),
|
|
role: "assistant".to_string(),
|
|
content: "Hi!".to_string(),
|
|
created_at: now + chrono::TimeDelta::seconds(1),
|
|
},
|
|
crate::history::ConversationMessage {
|
|
id: Uuid::new_v4(),
|
|
role: "user".to_string(),
|
|
content: "Lost message".to_string(),
|
|
created_at: now + chrono::TimeDelta::seconds(2),
|
|
},
|
|
];
|
|
|
|
let turns = build_turns_from_db_messages(&messages);
|
|
assert_eq!(turns.len(), 2);
|
|
assert_eq!(turns[1].user_input, "Lost message");
|
|
assert!(turns[1].response.is_none());
|
|
assert_eq!(turns[1].state, "Failed");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_turns_from_db_messages_empty() {
|
|
let turns = build_turns_from_db_messages(&[]);
|
|
assert!(turns.is_empty());
|
|
}
|
|
|
|
// --- OAuth callback handler tests ---
|
|
|
|
/// Build a minimal `GatewayState` for testing the OAuth callback handler.
|
|
fn test_gateway_state(ext_mgr: Option<Arc<ExtensionManager>>) -> Arc<GatewayState> {
|
|
Arc::new(GatewayState {
|
|
msg_tx: tokio::sync::RwLock::new(None),
|
|
sse: SseManager::new(),
|
|
workspace: None,
|
|
session_manager: None,
|
|
log_broadcaster: None,
|
|
log_level_handle: None,
|
|
extension_manager: ext_mgr,
|
|
tool_registry: None,
|
|
store: None,
|
|
job_manager: None,
|
|
prompt_queue: None,
|
|
user_id: "test".to_string(),
|
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
|
ws_tracker: None,
|
|
llm_provider: None,
|
|
skill_registry: None,
|
|
skill_catalog: None,
|
|
scheduler: None,
|
|
chat_rate_limiter: RateLimiter::new(30, 60),
|
|
oauth_rate_limiter: RateLimiter::new(10, 60),
|
|
registry_entries: vec![],
|
|
cost_guard: None,
|
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
|
startup_time: std::time::Instant::now(),
|
|
})
|
|
}
|
|
|
|
/// Build a test router with just the OAuth callback route.
|
|
fn test_oauth_router(state: Arc<GatewayState>) -> Router {
|
|
Router::new()
|
|
.route("/oauth/callback", get(oauth_callback_handler))
|
|
.with_state(state)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_callback_missing_params() {
|
|
use axum::body::Body;
|
|
use tower::ServiceExt;
|
|
|
|
let state = test_gateway_state(None);
|
|
let app = test_oauth_router(state);
|
|
|
|
let req = axum::http::Request::builder()
|
|
.uri("/oauth/callback")
|
|
.body(Body::empty())
|
|
.expect("request");
|
|
|
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
|
.await
|
|
.expect("body");
|
|
let html = String::from_utf8_lossy(&body);
|
|
assert!(html.contains("Authorization Failed"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_callback_error_from_provider() {
|
|
use axum::body::Body;
|
|
use tower::ServiceExt;
|
|
|
|
let state = test_gateway_state(None);
|
|
let app = test_oauth_router(state);
|
|
|
|
let req = axum::http::Request::builder()
|
|
.uri("/oauth/callback?error=access_denied&error_description=access_denied")
|
|
.body(Body::empty())
|
|
.expect("request");
|
|
|
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
|
.await
|
|
.expect("body");
|
|
let html = String::from_utf8_lossy(&body);
|
|
assert!(html.contains("Authorization Failed"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_callback_unknown_state() {
|
|
use axum::body::Body;
|
|
use tower::ServiceExt;
|
|
|
|
// Build an ExtensionManager so the handler can look up flows
|
|
let secrets = Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
|
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
|
TEST_GATEWAY_CRYPTO_KEY.to_string(),
|
|
))
|
|
.expect("crypto"),
|
|
)));
|
|
let tool_registry = Arc::new(ToolRegistry::new());
|
|
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
|
|
|
|
let ext_mgr = Arc::new(ExtensionManager::new(
|
|
mcp_sm,
|
|
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
|
|
secrets,
|
|
tool_registry,
|
|
None,
|
|
None,
|
|
std::path::PathBuf::from("/tmp/wasm_tools"),
|
|
std::path::PathBuf::from("/tmp/wasm_channels"),
|
|
None,
|
|
"test".to_string(),
|
|
None,
|
|
vec![],
|
|
));
|
|
|
|
let state = test_gateway_state(Some(ext_mgr));
|
|
let app = test_oauth_router(state);
|
|
|
|
let req = axum::http::Request::builder()
|
|
.uri("/oauth/callback?code=test_code&state=unknown_state_value")
|
|
.body(Body::empty())
|
|
.expect("request");
|
|
|
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
|
.await
|
|
.expect("body");
|
|
let html = String::from_utf8_lossy(&body);
|
|
assert!(html.contains("Authorization Failed"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_callback_expired_flow() {
|
|
use axum::body::Body;
|
|
use tower::ServiceExt;
|
|
|
|
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
|
|
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
|
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
|
TEST_GATEWAY_CRYPTO_KEY.to_string(),
|
|
))
|
|
.expect("crypto"),
|
|
)));
|
|
let tool_registry = Arc::new(ToolRegistry::new());
|
|
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
|
|
|
|
let ext_mgr = Arc::new(ExtensionManager::new(
|
|
mcp_sm,
|
|
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
|
|
secrets.clone(),
|
|
tool_registry,
|
|
None,
|
|
None,
|
|
std::path::PathBuf::from("/tmp/wasm_tools"),
|
|
std::path::PathBuf::from("/tmp/wasm_channels"),
|
|
None,
|
|
"test".to_string(),
|
|
None,
|
|
vec![],
|
|
));
|
|
|
|
// Insert an expired flow (created 10 minutes ago)
|
|
let flow = crate::cli::oauth_defaults::PendingOAuthFlow {
|
|
extension_name: "test_tool".to_string(),
|
|
display_name: "Test Tool".to_string(),
|
|
token_url: "https://example.com/token".to_string(),
|
|
client_id: "client123".to_string(),
|
|
client_secret: None,
|
|
redirect_uri: "https://example.com/oauth/callback".to_string(),
|
|
code_verifier: None,
|
|
access_token_field: "access_token".to_string(),
|
|
secret_name: "test_token".to_string(),
|
|
provider: None,
|
|
validation_endpoint: None,
|
|
scopes: vec![],
|
|
user_id: "test".to_string(),
|
|
secrets,
|
|
sse_sender: None,
|
|
gateway_token: None,
|
|
created_at: std::time::Instant::now()
|
|
.checked_sub(std::time::Duration::from_secs(600))
|
|
.expect("System uptime is too low to run expired flow test"),
|
|
};
|
|
|
|
ext_mgr
|
|
.pending_oauth_flows()
|
|
.write()
|
|
.await
|
|
.insert("expired_state".to_string(), flow);
|
|
|
|
let state = test_gateway_state(Some(ext_mgr));
|
|
let app = test_oauth_router(state);
|
|
|
|
let req = axum::http::Request::builder()
|
|
.uri("/oauth/callback?code=test_code&state=expired_state")
|
|
.body(Body::empty())
|
|
.expect("request");
|
|
|
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
|
.await
|
|
.expect("body");
|
|
let html = String::from_utf8_lossy(&body);
|
|
// Expired flow → error landing page
|
|
assert!(html.contains("Authorization Failed"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_callback_no_extension_manager() {
|
|
use axum::body::Body;
|
|
use tower::ServiceExt;
|
|
|
|
// No extension manager set → graceful error
|
|
let state = test_gateway_state(None);
|
|
let app = test_oauth_router(state);
|
|
|
|
let req = axum::http::Request::builder()
|
|
.uri("/oauth/callback?code=test_code&state=some_state")
|
|
.body(Body::empty())
|
|
.expect("request");
|
|
|
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
|
.await
|
|
.expect("body");
|
|
let html = String::from_utf8_lossy(&body);
|
|
assert!(html.contains("Authorization Failed"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_callback_strips_instance_prefix() {
|
|
use axum::body::Body;
|
|
use tower::ServiceExt;
|
|
|
|
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
|
|
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
|
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
|
TEST_GATEWAY_CRYPTO_KEY.to_string(),
|
|
))
|
|
.expect("crypto"),
|
|
)));
|
|
let tool_registry = Arc::new(ToolRegistry::new());
|
|
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
|
|
|
|
let ext_mgr = Arc::new(ExtensionManager::new(
|
|
mcp_sm,
|
|
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
|
|
secrets.clone(),
|
|
tool_registry,
|
|
None,
|
|
None,
|
|
std::path::PathBuf::from("/tmp/wasm_tools"),
|
|
std::path::PathBuf::from("/tmp/wasm_channels"),
|
|
None,
|
|
"test".to_string(),
|
|
None,
|
|
vec![],
|
|
));
|
|
|
|
// Insert a flow keyed by raw nonce "test_nonce" (without instance prefix).
|
|
// Use an expired flow so the handler exits before attempting a real HTTP
|
|
// token exchange — we only need to verify that the instance prefix was
|
|
// stripped and the flow was found by the raw nonce.
|
|
let flow = crate::cli::oauth_defaults::PendingOAuthFlow {
|
|
extension_name: "test_tool".to_string(),
|
|
display_name: "Test Tool".to_string(),
|
|
token_url: "https://example.com/token".to_string(),
|
|
client_id: "client123".to_string(),
|
|
client_secret: None,
|
|
redirect_uri: "https://example.com/oauth/callback".to_string(),
|
|
code_verifier: None,
|
|
access_token_field: "access_token".to_string(),
|
|
secret_name: "test_token".to_string(),
|
|
provider: None,
|
|
validation_endpoint: None,
|
|
scopes: vec![],
|
|
user_id: "test".to_string(),
|
|
secrets,
|
|
sse_sender: None,
|
|
gateway_token: None,
|
|
// Expired — handler will reject after lookup (no network I/O)
|
|
created_at: std::time::Instant::now()
|
|
.checked_sub(std::time::Duration::from_secs(600))
|
|
.expect("System uptime is too low to run expired flow test"),
|
|
};
|
|
|
|
ext_mgr
|
|
.pending_oauth_flows()
|
|
.write()
|
|
.await
|
|
.insert("test_nonce".to_string(), flow);
|
|
|
|
let state = test_gateway_state(Some(ext_mgr.clone()));
|
|
let app = test_oauth_router(state);
|
|
|
|
// Send callback with instance prefix: "myinstance:test_nonce"
|
|
// The handler should strip "myinstance:" and find the flow keyed by "test_nonce"
|
|
let req = axum::http::Request::builder()
|
|
.uri("/oauth/callback?code=fake_code&state=myinstance:test_nonce")
|
|
.body(Body::empty())
|
|
.expect("request");
|
|
|
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
|
.await
|
|
.expect("response");
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
|
.await
|
|
.expect("body");
|
|
let html = String::from_utf8_lossy(&body);
|
|
|
|
// The flow was found (stripped prefix matched) but is expired, so the
|
|
// handler returns an error landing page. The flow being consumed from
|
|
// the registry (checked below) proves the prefix was stripped correctly.
|
|
assert!(
|
|
html.contains("Authorization Failed"),
|
|
"Expected error page, html was: {}",
|
|
&html[..html.len().min(500)]
|
|
);
|
|
|
|
// Verify the flow was consumed (removed from registry)
|
|
assert!(
|
|
ext_mgr
|
|
.pending_oauth_flows()
|
|
.read()
|
|
.await
|
|
.get("test_nonce")
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
// --- Slack relay OAuth CSRF tests ---
|
|
|
|
fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router {
|
|
Router::new()
|
|
.route(
|
|
"/oauth/slack/callback",
|
|
get(slack_relay_oauth_callback_handler),
|
|
)
|
|
.with_state(state)
|
|
}
|
|
|
|
fn test_secrets_store() -> Arc<dyn crate::secrets::SecretsStore + Send + Sync> {
|
|
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
|
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
|
"test-key-at-least-32-chars-long!!".to_string(),
|
|
))
|
|
.expect("crypto"),
|
|
)))
|
|
}
|
|
|
|
fn test_ext_mgr(
|
|
secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
|
|
) -> Arc<ExtensionManager> {
|
|
let tool_registry = Arc::new(ToolRegistry::new());
|
|
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
|
|
let mcp_pm = Arc::new(crate::tools::mcp::process::McpProcessManager::new());
|
|
Arc::new(ExtensionManager::new(
|
|
mcp_sm,
|
|
mcp_pm,
|
|
secrets,
|
|
tool_registry,
|
|
None,
|
|
None,
|
|
std::path::PathBuf::from("/tmp/wasm_tools"),
|
|
std::path::PathBuf::from("/tmp/wasm_channels"),
|
|
None,
|
|
"test".to_string(),
|
|
None,
|
|
vec![],
|
|
))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_relay_oauth_callback_missing_state_param() {
|
|
use axum::body::Body;
|
|
use tower::ServiceExt;
|
|
|
|
let secrets = test_secrets_store();
|
|
let ext_mgr = test_ext_mgr(secrets);
|
|
let state = test_gateway_state(Some(ext_mgr));
|
|
let app = test_relay_oauth_router(state);
|
|
|
|
// Callback without state param should be rejected
|
|
let req = axum::http::Request::builder()
|
|
.uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack")
|
|
.body(Body::empty())
|
|
.expect("request");
|
|
|
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
|
.await
|
|
.expect("response");
|
|
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
|
.await
|
|
.expect("body");
|
|
let html = String::from_utf8_lossy(&body);
|
|
assert!(
|
|
html.contains("Invalid or expired authorization"),
|
|
"Expected CSRF error, got: {}",
|
|
&html[..html.len().min(300)]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_relay_oauth_callback_wrong_state_param() {
|
|
use axum::body::Body;
|
|
use tower::ServiceExt;
|
|
|
|
let secrets = test_secrets_store();
|
|
|
|
// Store a valid nonce
|
|
secrets
|
|
.create(
|
|
"test",
|
|
crate::secrets::CreateSecretParams::new(
|
|
format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME),
|
|
"correct-nonce-value",
|
|
),
|
|
)
|
|
.await
|
|
.expect("store nonce");
|
|
|
|
let ext_mgr = test_ext_mgr(secrets);
|
|
let state = test_gateway_state(Some(ext_mgr));
|
|
let app = test_relay_oauth_router(state);
|
|
|
|
// Callback with wrong state param
|
|
let req = axum::http::Request::builder()
|
|
.uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state=wrong-nonce")
|
|
.body(Body::empty())
|
|
.expect("request");
|
|
|
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
|
.await
|
|
.expect("response");
|
|
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
|
.await
|
|
.expect("body");
|
|
let html = String::from_utf8_lossy(&body);
|
|
assert!(
|
|
html.contains("Invalid or expired authorization"),
|
|
"Expected CSRF error for wrong nonce, got: {}",
|
|
&html[..html.len().min(300)]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_relay_oauth_callback_correct_state_proceeds() {
|
|
use axum::body::Body;
|
|
use tower::ServiceExt;
|
|
|
|
let secrets = test_secrets_store();
|
|
let nonce = "valid-test-nonce-12345";
|
|
|
|
// Store the correct nonce
|
|
secrets
|
|
.create(
|
|
"test",
|
|
crate::secrets::CreateSecretParams::new(
|
|
format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME),
|
|
nonce,
|
|
),
|
|
)
|
|
.await
|
|
.expect("store nonce");
|
|
|
|
let ext_mgr = test_ext_mgr(secrets.clone());
|
|
let state = test_gateway_state(Some(ext_mgr));
|
|
let app = test_relay_oauth_router(state);
|
|
|
|
// Callback with correct state param — will pass CSRF check
|
|
// but may fail downstream (no real relay service) — that's OK,
|
|
// we just verify it doesn't return a CSRF error.
|
|
let req = axum::http::Request::builder()
|
|
.uri(format!(
|
|
"/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state={}",
|
|
nonce
|
|
))
|
|
.body(Body::empty())
|
|
.expect("request");
|
|
|
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
|
.await
|
|
.expect("response");
|
|
|
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
|
.await
|
|
.expect("body");
|
|
let html = String::from_utf8_lossy(&body);
|
|
// Should NOT contain the CSRF error message
|
|
assert!(
|
|
!html.contains("Invalid or expired authorization"),
|
|
"Should have passed CSRF check, got: {}",
|
|
&html[..html.len().min(300)]
|
|
);
|
|
|
|
// Verify the nonce was consumed (deleted)
|
|
let state_key = format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME);
|
|
let exists = secrets.exists("test", &state_key).await.unwrap_or(true);
|
|
assert!(!exists, "CSRF nonce should be deleted after use");
|
|
}
|
|
}
|