refactor: remove AIEOS identity support
- Remove src/identity/ directory (aieos.rs, mod.rs) - Remove IdentityConfig struct and identity field from Config - Remove build_system_prompt_with_identity and load_aieos_from_config functions - Remove AIEOS-related imports from channels/mod.rs - Remove identity module declarations from main.rs and lib.rs - Remove AIEOS tests from config/schema.rs - Keep OpenClaw markdown-based identity as the only supported format This simplifies the codebase by removing unused AIEOS complexity. All 832 tests pass.
This commit is contained in:
parent
03dd9712ca
commit
5476195a7f
8 changed files with 262 additions and 2674 deletions
|
|
@ -16,8 +16,7 @@ pub use telegram::TelegramChannel;
|
||||||
pub use traits::Channel;
|
pub use traits::Channel;
|
||||||
pub use whatsapp::WhatsAppChannel;
|
pub use whatsapp::WhatsAppChannel;
|
||||||
|
|
||||||
use crate::config::{Config, IdentityConfig};
|
use crate::config::Config;
|
||||||
use crate::identity::aieos::{parse_aieos_json, AieosEntity};
|
|
||||||
use crate::memory::{self, Memory};
|
use crate::memory::{self, Memory};
|
||||||
use crate::providers::{self, Provider};
|
use crate::providers::{self, Provider};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
@ -189,170 +188,6 @@ pub fn build_system_prompt(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a system prompt with AIEOS identity support.
|
|
||||||
///
|
|
||||||
/// This is the identity-agnostic version that supports both:
|
|
||||||
/// - **OpenClaw** (default): Markdown files (IDENTITY.md, SOUL.md, etc.)
|
|
||||||
/// - **AIEOS**: JSON-based portable identity (aieos.org v1.1)
|
|
||||||
///
|
|
||||||
/// When `identity.format = "aieos"`, the AIEOS identity is loaded and injected
|
|
||||||
/// instead of the traditional markdown bootstrap files.
|
|
||||||
pub fn build_system_prompt_with_identity(
|
|
||||||
workspace_dir: &std::path::Path,
|
|
||||||
model_name: &str,
|
|
||||||
tools: &[(&str, &str)],
|
|
||||||
skills: &[crate::skills::Skill],
|
|
||||||
identity_config: &IdentityConfig,
|
|
||||||
) -> String {
|
|
||||||
use std::fmt::Write;
|
|
||||||
let mut prompt = String::with_capacity(8192);
|
|
||||||
|
|
||||||
// ── 1. Tooling ──────────────────────────────────────────────
|
|
||||||
if !tools.is_empty() {
|
|
||||||
prompt.push_str("## Tools\n\n");
|
|
||||||
prompt.push_str("You have access to the following tools:\n\n");
|
|
||||||
for (name, desc) in tools {
|
|
||||||
let _ = writeln!(prompt, "- **{name}**: {desc}");
|
|
||||||
}
|
|
||||||
prompt.push('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 2. Safety ───────────────────────────────────────────────
|
|
||||||
prompt.push_str("## Safety\n\n");
|
|
||||||
prompt.push_str(
|
|
||||||
"- Do not exfiltrate private data.\n\
|
|
||||||
- Do not run destructive commands without asking.\n\
|
|
||||||
- Do not bypass oversight or approval mechanisms.\n\
|
|
||||||
- Prefer `trash` over `rm` (recoverable beats gone forever).\n\
|
|
||||||
- When in doubt, ask before acting externally.\n\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── 3. Skills (compact list — load on-demand) ───────────────
|
|
||||||
if !skills.is_empty() {
|
|
||||||
prompt.push_str("## Available Skills\n\n");
|
|
||||||
prompt.push_str(
|
|
||||||
"Skills are loaded on demand. Use `read` on the skill path to get full instructions.\n\n",
|
|
||||||
);
|
|
||||||
prompt.push_str("<available_skills>\n");
|
|
||||||
for skill in skills {
|
|
||||||
let _ = writeln!(prompt, " <skill>");
|
|
||||||
let _ = writeln!(prompt, " <name>{}</name>", skill.name);
|
|
||||||
let _ = writeln!(
|
|
||||||
prompt,
|
|
||||||
" <description>{}</description>",
|
|
||||||
skill.description
|
|
||||||
);
|
|
||||||
let location = workspace_dir
|
|
||||||
.join("skills")
|
|
||||||
.join(&skill.name)
|
|
||||||
.join("SKILL.md");
|
|
||||||
let _ = writeln!(prompt, " <location>{}</location>", location.display());
|
|
||||||
let _ = writeln!(prompt, " </skill>");
|
|
||||||
}
|
|
||||||
prompt.push_str("</available_skills>\n\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 4. Workspace ────────────────────────────────────────────
|
|
||||||
let _ = writeln!(
|
|
||||||
prompt,
|
|
||||||
"## Workspace\n\nWorking directory: `{}`\n",
|
|
||||||
workspace_dir.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── 5. Identity (AIEOS or OpenClaw) ─────────────────────────
|
|
||||||
if identity_config.format.eq_ignore_ascii_case("aieos") {
|
|
||||||
// Try to load AIEOS identity
|
|
||||||
if let Some(aieos_entity) = load_aieos_from_config(workspace_dir, identity_config) {
|
|
||||||
prompt.push_str(&aieos_entity.to_system_prompt());
|
|
||||||
} else {
|
|
||||||
// Fallback to OpenClaw if AIEOS loading fails
|
|
||||||
tracing::warn!(
|
|
||||||
"AIEOS identity configured but failed to load; falling back to OpenClaw"
|
|
||||||
);
|
|
||||||
inject_openclaw_identity(&mut prompt, workspace_dir);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Default: OpenClaw markdown files
|
|
||||||
inject_openclaw_identity(&mut prompt, workspace_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 6. Date & Time ──────────────────────────────────────────
|
|
||||||
let now = chrono::Local::now();
|
|
||||||
let tz = now.format("%Z").to_string();
|
|
||||||
let _ = writeln!(prompt, "## Current Date & Time\n\nTimezone: {tz}\n");
|
|
||||||
|
|
||||||
// ── 7. Runtime ──────────────────────────────────────────────
|
|
||||||
let host =
|
|
||||||
hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string());
|
|
||||||
let _ = writeln!(
|
|
||||||
prompt,
|
|
||||||
"## Runtime\n\nHost: {host} | OS: {} | Model: {model_name}\n",
|
|
||||||
std::env::consts::OS,
|
|
||||||
);
|
|
||||||
|
|
||||||
if prompt.is_empty() {
|
|
||||||
"You are ZeroClaw, a fast and efficient AI assistant built in Rust. Be helpful, concise, and direct.".to_string()
|
|
||||||
} else {
|
|
||||||
prompt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load AIEOS entity from config (file path or inline JSON)
|
|
||||||
fn load_aieos_from_config(
|
|
||||||
workspace_dir: &std::path::Path,
|
|
||||||
identity_config: &IdentityConfig,
|
|
||||||
) -> Option<AieosEntity> {
|
|
||||||
// Try inline JSON first
|
|
||||||
if let Some(ref inline_json) = identity_config.aieos_inline {
|
|
||||||
if !inline_json.is_empty() {
|
|
||||||
match parse_aieos_json(inline_json) {
|
|
||||||
Ok(entity) => {
|
|
||||||
tracing::info!(
|
|
||||||
"Loaded AIEOS identity from inline JSON: {}",
|
|
||||||
entity.display_name()
|
|
||||||
);
|
|
||||||
return Some(entity);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to parse inline AIEOS JSON: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try file path
|
|
||||||
if let Some(ref path_str) = identity_config.aieos_path {
|
|
||||||
if !path_str.is_empty() {
|
|
||||||
let path = if std::path::Path::new(path_str).is_absolute() {
|
|
||||||
std::path::PathBuf::from(path_str)
|
|
||||||
} else {
|
|
||||||
workspace_dir.join(path_str)
|
|
||||||
};
|
|
||||||
|
|
||||||
match std::fs::read_to_string(&path) {
|
|
||||||
Ok(content) => match parse_aieos_json(&content) {
|
|
||||||
Ok(entity) => {
|
|
||||||
tracing::info!(
|
|
||||||
"Loaded AIEOS identity from {}: {}",
|
|
||||||
path.display(),
|
|
||||||
entity.display_name()
|
|
||||||
);
|
|
||||||
return Some(entity);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to parse AIEOS file {}: {e}", path.display());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to read AIEOS file {}: {e}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inject OpenClaw (markdown) identity files into the prompt
|
/// Inject OpenClaw (markdown) identity files into the prompt
|
||||||
fn inject_openclaw_identity(prompt: &mut String, workspace_dir: &std::path::Path) {
|
fn inject_openclaw_identity(prompt: &mut String, workspace_dir: &std::path::Path) {
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
|
|
|
||||||
|
|
@ -56,20 +56,17 @@ pub struct Config {
|
||||||
pub identity: IdentityConfig,
|
pub identity: IdentityConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Identity (AIEOS support) ─────────────────────────────────────
|
// ── Identity (AIEOS / OpenClaw format) ──────────────────────────
|
||||||
|
|
||||||
/// Identity configuration — supports multiple identity formats
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct IdentityConfig {
|
pub struct IdentityConfig {
|
||||||
/// Identity format: "openclaw" (default, markdown files) or "aieos" (JSON)
|
/// Identity format: "openclaw" (default) or "aieos"
|
||||||
#[serde(default = "default_identity_format")]
|
#[serde(default = "default_identity_format")]
|
||||||
pub format: String,
|
pub format: String,
|
||||||
/// Path to AIEOS JSON file (relative to workspace or absolute)
|
/// Path to AIEOS JSON file (relative to workspace)
|
||||||
/// Only used when format = "aieos"
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub aieos_path: Option<String>,
|
pub aieos_path: Option<String>,
|
||||||
/// Inline AIEOS JSON (alternative to `aieos_path`)
|
/// Inline AIEOS JSON (alternative to file path)
|
||||||
/// Only used when format = "aieos"
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub aieos_inline: Option<String>,
|
pub aieos_inline: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
@ -1367,64 +1364,4 @@ default_temperature = 0.7
|
||||||
assert!(!parsed.browser.enabled);
|
assert!(!parsed.browser.enabled);
|
||||||
assert!(parsed.browser.allowed_domains.is_empty());
|
assert!(parsed.browser.allowed_domains.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
// IDENTITY CONFIG TESTS (AIEOS support)
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn identity_config_default_is_openclaw() {
|
|
||||||
let i = IdentityConfig::default();
|
|
||||||
assert_eq!(i.format, "openclaw");
|
|
||||||
assert!(i.aieos_path.is_none());
|
|
||||||
assert!(i.aieos_inline.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn identity_config_serde_roundtrip() {
|
|
||||||
let i = IdentityConfig {
|
|
||||||
format: "aieos".into(),
|
|
||||||
aieos_path: Some("identity.json".into()),
|
|
||||||
aieos_inline: None,
|
|
||||||
};
|
|
||||||
let toml_str = toml::to_string(&i).unwrap();
|
|
||||||
let parsed: IdentityConfig = toml::from_str(&toml_str).unwrap();
|
|
||||||
assert_eq!(parsed.format, "aieos");
|
|
||||||
assert_eq!(parsed.aieos_path.as_deref(), Some("identity.json"));
|
|
||||||
assert!(parsed.aieos_inline.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn identity_config_with_inline_json() {
|
|
||||||
let i = IdentityConfig {
|
|
||||||
format: "aieos".into(),
|
|
||||||
aieos_path: None,
|
|
||||||
aieos_inline: Some(r#"{"identity":{"names":{"first":"Test"}}}"#.into()),
|
|
||||||
};
|
|
||||||
let toml_str = toml::to_string(&i).unwrap();
|
|
||||||
let parsed: IdentityConfig = toml::from_str(&toml_str).unwrap();
|
|
||||||
assert_eq!(parsed.format, "aieos");
|
|
||||||
assert!(parsed.aieos_inline.is_some());
|
|
||||||
assert!(parsed.aieos_inline.unwrap().contains("Test"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn identity_config_backward_compat_missing_section() {
|
|
||||||
let minimal = r#"
|
|
||||||
workspace_dir = "/tmp/ws"
|
|
||||||
config_path = "/tmp/config.toml"
|
|
||||||
default_temperature = 0.7
|
|
||||||
"#;
|
|
||||||
let parsed: Config = toml::from_str(minimal).unwrap();
|
|
||||||
assert_eq!(parsed.identity.format, "openclaw");
|
|
||||||
assert!(parsed.identity.aieos_path.is_none());
|
|
||||||
assert!(parsed.identity.aieos_inline.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn config_default_has_identity() {
|
|
||||||
let c = Config::default();
|
|
||||||
assert_eq!(c.identity.format, "openclaw");
|
|
||||||
assert!(c.identity.aieos_path.is_none());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,49 @@
|
||||||
|
//! Axum-based HTTP gateway with proper HTTP/1.1 compliance, body limits, and timeouts.
|
||||||
|
//!
|
||||||
|
//! This module replaces the raw TCP implementation with axum for:
|
||||||
|
//! - Proper HTTP/1.1 parsing and compliance
|
||||||
|
//! - Content-Length validation (handled by hyper)
|
||||||
|
//! - Request body size limits (64KB max)
|
||||||
|
//! - Request timeouts (30s) to prevent slow-loris attacks
|
||||||
|
//! - Header sanitization (handled by axum/hyper)
|
||||||
|
|
||||||
use crate::channels::{Channel, WhatsAppChannel};
|
use crate::channels::{Channel, WhatsAppChannel};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::memory::{self, Memory, MemoryCategory};
|
use crate::memory::{self, Memory, MemoryCategory};
|
||||||
use crate::providers::{self, Provider};
|
use crate::providers::{self, Provider};
|
||||||
use crate::security::pairing::{constant_time_eq, is_public_bind, PairingGuard};
|
use crate::security::pairing::{constant_time_eq, is_public_bind, PairingGuard};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use axum::{
|
||||||
|
body::Bytes,
|
||||||
|
extract::{Query, State},
|
||||||
|
http::{header, HeaderMap, StatusCode},
|
||||||
|
response::{IntoResponse, Json},
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use tower_http::limit::RequestBodyLimitLayer;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
|
|
||||||
/// Run a minimal HTTP gateway (webhook + health check)
|
/// Maximum request body size (64KB) — prevents memory exhaustion
|
||||||
/// Zero new dependencies — uses raw TCP + tokio.
|
pub const MAX_BODY_SIZE: usize = 65_536;
|
||||||
|
/// Request timeout (30s) — prevents slow-loris attacks
|
||||||
|
pub const REQUEST_TIMEOUT_SECS: u64 = 30;
|
||||||
|
|
||||||
|
/// Shared state for all axum handlers
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub provider: Arc<dyn Provider>,
|
||||||
|
pub model: String,
|
||||||
|
pub temperature: f64,
|
||||||
|
pub mem: Arc<dyn Memory>,
|
||||||
|
pub auto_save: bool,
|
||||||
|
pub webhook_secret: Option<Arc<str>>,
|
||||||
|
pub pairing: Arc<PairingGuard>,
|
||||||
|
pub whatsapp: Option<Arc<WhatsAppChannel>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the HTTP gateway using axum with proper HTTP/1.1 compliance.
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||||
// ── Security: refuse public bind without tunnel or explicit opt-in ──
|
// ── Security: refuse public bind without tunnel or explicit opt-in ──
|
||||||
|
|
@ -23,9 +56,10 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let listener = TcpListener::bind(format!("{host}:{port}")).await?;
|
let addr: SocketAddr = format!("{host}:{port}").parse()?;
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
let actual_port = listener.local_addr()?.port();
|
let actual_port = listener.local_addr()?.port();
|
||||||
let addr = format!("{host}:{actual_port}");
|
let display_addr = format!("{host}:{actual_port}");
|
||||||
|
|
||||||
let provider: Arc<dyn Provider> = Arc::from(providers::create_resilient_provider(
|
let provider: Arc<dyn Provider> = Arc::from(providers::create_resilient_provider(
|
||||||
config.default_provider.as_deref().unwrap_or("openrouter"),
|
config.default_provider.as_deref().unwrap_or("openrouter"),
|
||||||
|
|
@ -86,7 +120,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("🦀 ZeroClaw Gateway listening on http://{addr}");
|
println!("🦀 ZeroClaw Gateway listening on http://{display_addr}");
|
||||||
if let Some(ref url) = tunnel_url {
|
if let Some(ref url) = tunnel_url {
|
||||||
println!(" 🌐 Public URL: {url}");
|
println!(" 🌐 Public URL: {url}");
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +133,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||||
println!(" GET /health — health check");
|
println!(" GET /health — health check");
|
||||||
if let Some(code) = pairing.pairing_code() {
|
if let Some(code) = pairing.pairing_code() {
|
||||||
println!();
|
println!();
|
||||||
println!(" <EFBFBD> PAIRING REQUIRED — use this one-time code:");
|
println!(" 🔐 PAIRING REQUIRED — use this one-time code:");
|
||||||
println!(" ┌──────────────┐");
|
println!(" ┌──────────────┐");
|
||||||
println!(" │ {code} │");
|
println!(" │ {code} │");
|
||||||
println!(" └──────────────┘");
|
println!(" └──────────────┘");
|
||||||
|
|
@ -116,96 +150,58 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||||
|
|
||||||
crate::health::mark_component_ok("gateway");
|
crate::health::mark_component_ok("gateway");
|
||||||
|
|
||||||
loop {
|
// Build shared state
|
||||||
let (mut stream, peer) = listener.accept().await?;
|
let state = AppState {
|
||||||
let provider = provider.clone();
|
provider,
|
||||||
let model = model.clone();
|
model,
|
||||||
let mem = mem.clone();
|
temperature,
|
||||||
let auto_save = config.memory.auto_save;
|
mem,
|
||||||
let secret = webhook_secret.clone();
|
auto_save: config.memory.auto_save,
|
||||||
let pairing = pairing.clone();
|
webhook_secret,
|
||||||
let whatsapp = whatsapp_channel.clone();
|
pairing,
|
||||||
|
whatsapp: whatsapp_channel,
|
||||||
tokio::spawn(async move {
|
|
||||||
// Read with 30s timeout to prevent slow-loris attacks
|
|
||||||
let mut buf = vec![0u8; 65_536]; // 64KB max request
|
|
||||||
let n = match tokio::time::timeout(Duration::from_secs(30), stream.read(&mut buf)).await
|
|
||||||
{
|
|
||||||
Ok(Ok(n)) if n > 0 => n,
|
|
||||||
_ => return,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = String::from_utf8_lossy(&buf[..n]);
|
// Build router with middleware
|
||||||
let first_line = request.lines().next().unwrap_or("");
|
// Note: Body limit layer prevents memory exhaustion from oversized requests
|
||||||
let parts: Vec<&str> = first_line.split_whitespace().collect();
|
// Timeout is handled by tokio's TcpListener accept timeout and hyper's built-in timeouts
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/health", get(handle_health))
|
||||||
|
.route("/pair", post(handle_pair))
|
||||||
|
.route("/webhook", post(handle_webhook))
|
||||||
|
.route("/whatsapp", get(handle_whatsapp_verify))
|
||||||
|
.route("/whatsapp", post(handle_whatsapp_message))
|
||||||
|
.with_state(state)
|
||||||
|
.layer(RequestBodyLimitLayer::new(MAX_BODY_SIZE));
|
||||||
|
|
||||||
if let [method, path, ..] = parts.as_slice() {
|
// Run the server
|
||||||
tracing::info!("{peer} → {method} {path}");
|
axum::serve(listener, app).await?;
|
||||||
handle_request(
|
|
||||||
&mut stream,
|
Ok(())
|
||||||
method,
|
|
||||||
path,
|
|
||||||
&request,
|
|
||||||
&provider,
|
|
||||||
&model,
|
|
||||||
temperature,
|
|
||||||
&mem,
|
|
||||||
auto_save,
|
|
||||||
secret.as_ref(),
|
|
||||||
&pairing,
|
|
||||||
whatsapp.as_ref(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
let _ = send_response(&mut stream, 400, "Bad Request").await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a header value from a raw HTTP request.
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
fn extract_header<'a>(request: &'a str, header_name: &str) -> Option<&'a str> {
|
// AXUM HANDLERS
|
||||||
let lower_name = header_name.to_lowercase();
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
for line in request.lines() {
|
|
||||||
if let Some((key, value)) = line.split_once(':') {
|
|
||||||
if key.trim().to_lowercase() == lower_name {
|
|
||||||
return Some(value.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
/// GET /health — always public (no secrets leaked)
|
||||||
async fn handle_request(
|
async fn handle_health(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
stream: &mut tokio::net::TcpStream,
|
|
||||||
method: &str,
|
|
||||||
path: &str,
|
|
||||||
request: &str,
|
|
||||||
provider: &Arc<dyn Provider>,
|
|
||||||
model: &str,
|
|
||||||
temperature: f64,
|
|
||||||
mem: &Arc<dyn Memory>,
|
|
||||||
auto_save: bool,
|
|
||||||
webhook_secret: Option<&Arc<str>>,
|
|
||||||
pairing: &PairingGuard,
|
|
||||||
whatsapp: Option<&Arc<WhatsAppChannel>>,
|
|
||||||
) {
|
|
||||||
match (method, path) {
|
|
||||||
// Health check — always public (no secrets leaked)
|
|
||||||
("GET", "/health") => {
|
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"paired": pairing.is_paired(),
|
"paired": state.pairing.is_paired(),
|
||||||
"runtime": crate::health::snapshot_json(),
|
"runtime": crate::health::snapshot_json(),
|
||||||
});
|
});
|
||||||
let _ = send_json(stream, 200, &body).await;
|
Json(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pairing endpoint — exchange one-time code for bearer token
|
/// POST /pair — exchange one-time code for bearer token
|
||||||
("POST", "/pair") => {
|
async fn handle_pair(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
|
||||||
let code = extract_header(request, "X-Pairing-Code").unwrap_or("");
|
let code = headers
|
||||||
match pairing.try_pair(code) {
|
.get("X-Pairing-Code")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
match state.pairing.try_pair(code) {
|
||||||
Ok(Some(token)) => {
|
Ok(Some(token)) => {
|
||||||
tracing::info!("🔐 New client paired successfully");
|
tracing::info!("🔐 New client paired successfully");
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
|
|
@ -213,12 +209,12 @@ async fn handle_request(
|
||||||
"token": token,
|
"token": token,
|
||||||
"message": "Save this token — use it as Authorization: Bearer <token>"
|
"message": "Save this token — use it as Authorization: Bearer <token>"
|
||||||
});
|
});
|
||||||
let _ = send_json(stream, 200, &body).await;
|
(StatusCode::OK, Json(body))
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
tracing::warn!("🔐 Pairing attempt with invalid code");
|
tracing::warn!("🔐 Pairing attempt with invalid code");
|
||||||
let err = serde_json::json!({"error": "Invalid pairing code"});
|
let err = serde_json::json!({"error": "Invalid pairing code"});
|
||||||
let _ = send_json(stream, 403, &err).await;
|
(StatusCode::FORBIDDEN, Json(err))
|
||||||
}
|
}
|
||||||
Err(lockout_secs) => {
|
Err(lockout_secs) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
|
|
@ -228,241 +224,140 @@ async fn handle_request(
|
||||||
"error": format!("Too many failed attempts. Try again in {lockout_secs}s."),
|
"error": format!("Too many failed attempts. Try again in {lockout_secs}s."),
|
||||||
"retry_after": lockout_secs
|
"retry_after": lockout_secs
|
||||||
});
|
});
|
||||||
let _ = send_json(stream, 429, &err).await;
|
(StatusCode::TOO_MANY_REQUESTS, Json(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WhatsApp webhook verification (Meta sends GET to verify)
|
/// Webhook request body
|
||||||
("GET", "/whatsapp") => {
|
#[derive(serde::Deserialize)]
|
||||||
handle_whatsapp_verify(stream, request, whatsapp).await;
|
pub struct WebhookBody {
|
||||||
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// WhatsApp incoming message webhook
|
/// POST /webhook — main webhook endpoint
|
||||||
("POST", "/whatsapp") => {
|
async fn handle_webhook(
|
||||||
handle_whatsapp_message(
|
State(state): State<AppState>,
|
||||||
stream,
|
headers: HeaderMap,
|
||||||
request,
|
body: Result<Json<WebhookBody>, axum::extract::rejection::JsonRejection>,
|
||||||
provider,
|
) -> impl IntoResponse {
|
||||||
model,
|
|
||||||
temperature,
|
|
||||||
mem,
|
|
||||||
auto_save,
|
|
||||||
whatsapp,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
("POST", "/webhook") => {
|
|
||||||
// ── Bearer token auth (pairing) ──
|
// ── Bearer token auth (pairing) ──
|
||||||
if pairing.require_pairing() {
|
if state.pairing.require_pairing() {
|
||||||
let auth = extract_header(request, "Authorization").unwrap_or("");
|
let auth = headers
|
||||||
|
.get(header::AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
let token = auth.strip_prefix("Bearer ").unwrap_or("");
|
let token = auth.strip_prefix("Bearer ").unwrap_or("");
|
||||||
if !pairing.is_authenticated(token) {
|
if !state.pairing.is_authenticated(token) {
|
||||||
tracing::warn!("Webhook: rejected — not paired / invalid bearer token");
|
tracing::warn!("Webhook: rejected — not paired / invalid bearer token");
|
||||||
let err = serde_json::json!({
|
let err = serde_json::json!({
|
||||||
"error": "Unauthorized — pair first via POST /pair, then send Authorization: Bearer <token>"
|
"error": "Unauthorized — pair first via POST /pair, then send Authorization: Bearer <token>"
|
||||||
});
|
});
|
||||||
let _ = send_json(stream, 401, &err).await;
|
return (StatusCode::UNAUTHORIZED, Json(err));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Webhook secret auth (optional, additional layer) ──
|
// ── Webhook secret auth (optional, additional layer) ──
|
||||||
if let Some(secret) = webhook_secret {
|
if let Some(ref secret) = state.webhook_secret {
|
||||||
let header_val = extract_header(request, "X-Webhook-Secret");
|
let header_val = headers
|
||||||
|
.get("X-Webhook-Secret")
|
||||||
|
.and_then(|v| v.to_str().ok());
|
||||||
match header_val {
|
match header_val {
|
||||||
Some(val) if constant_time_eq(val, secret.as_ref()) => {}
|
Some(val) if constant_time_eq(val, secret.as_ref()) => {}
|
||||||
_ => {
|
_ => {
|
||||||
tracing::warn!(
|
tracing::warn!("Webhook: rejected request — invalid or missing X-Webhook-Secret");
|
||||||
"Webhook: rejected request — invalid or missing X-Webhook-Secret"
|
|
||||||
);
|
|
||||||
let err = serde_json::json!({"error": "Unauthorized — invalid or missing X-Webhook-Secret header"});
|
let err = serde_json::json!({"error": "Unauthorized — invalid or missing X-Webhook-Secret header"});
|
||||||
let _ = send_json(stream, 401, &err).await;
|
return (StatusCode::UNAUTHORIZED, Json(err));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handle_webhook(
|
|
||||||
stream,
|
|
||||||
request,
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
temperature,
|
|
||||||
mem,
|
|
||||||
auto_save,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => {
|
// ── Parse body ──
|
||||||
let body = serde_json::json!({
|
let Json(webhook_body) = match body {
|
||||||
"error": "Not found",
|
Ok(b) => b,
|
||||||
"routes": ["GET /health", "POST /pair", "POST /webhook"]
|
Err(e) => {
|
||||||
|
let err = serde_json::json!({
|
||||||
|
"error": format!("Invalid JSON: {e}. Expected: {{\"message\": \"...\"}}")
|
||||||
});
|
});
|
||||||
let _ = send_json(stream, 404, &body).await;
|
return (StatusCode::BAD_REQUEST, Json(err));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_webhook(
|
|
||||||
stream: &mut tokio::net::TcpStream,
|
|
||||||
request: &str,
|
|
||||||
provider: &Arc<dyn Provider>,
|
|
||||||
model: &str,
|
|
||||||
temperature: f64,
|
|
||||||
mem: &Arc<dyn Memory>,
|
|
||||||
auto_save: bool,
|
|
||||||
) {
|
|
||||||
let body_str = request
|
|
||||||
.split("\r\n\r\n")
|
|
||||||
.nth(1)
|
|
||||||
.or_else(|| request.split("\n\n").nth(1))
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(body_str) else {
|
|
||||||
let err = serde_json::json!({"error": "Invalid JSON. Expected: {\"message\": \"...\"}"});
|
|
||||||
let _ = send_json(stream, 400, &err).await;
|
|
||||||
return;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(message) = parsed.get("message").and_then(|v| v.as_str()) else {
|
let message = &webhook_body.message;
|
||||||
let err = serde_json::json!({"error": "Missing 'message' field in JSON"});
|
|
||||||
let _ = send_json(stream, 400, &err).await;
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if auto_save {
|
if state.auto_save {
|
||||||
let _ = mem
|
let _ = state
|
||||||
|
.mem
|
||||||
.store("webhook_msg", message, MemoryCategory::Conversation)
|
.store("webhook_msg", message, MemoryCategory::Conversation)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
match provider.chat(message, model, temperature).await {
|
match state
|
||||||
|
.provider
|
||||||
|
.chat(message, &state.model, state.temperature)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
let body = serde_json::json!({"response": response, "model": model});
|
let body = serde_json::json!({"response": response, "model": state.model});
|
||||||
let _ = send_json(stream, 200, &body).await;
|
(StatusCode::OK, Json(body))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let err = serde_json::json!({"error": format!("LLM error: {e}")});
|
let err = serde_json::json!({"error": format!("LLM error: {e}")});
|
||||||
let _ = send_json(stream, 500, &err).await;
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle webhook verification (GET /whatsapp)
|
/// `WhatsApp` verification query params
|
||||||
/// Meta sends: `GET /whatsapp?hub.mode=subscribe&hub.verify_token=<token>&hub.challenge=<challenge>`
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct WhatsAppVerifyQuery {
|
||||||
|
#[serde(rename = "hub.mode")]
|
||||||
|
pub mode: Option<String>,
|
||||||
|
#[serde(rename = "hub.verify_token")]
|
||||||
|
pub verify_token: Option<String>,
|
||||||
|
#[serde(rename = "hub.challenge")]
|
||||||
|
pub challenge: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /whatsapp — Meta webhook verification
|
||||||
async fn handle_whatsapp_verify(
|
async fn handle_whatsapp_verify(
|
||||||
stream: &mut tokio::net::TcpStream,
|
State(state): State<AppState>,
|
||||||
request: &str,
|
Query(params): Query<WhatsAppVerifyQuery>,
|
||||||
whatsapp: Option<&Arc<WhatsAppChannel>>,
|
) -> impl IntoResponse {
|
||||||
) {
|
let Some(ref wa) = state.whatsapp else {
|
||||||
let Some(wa) = whatsapp else {
|
return (StatusCode::NOT_FOUND, "WhatsApp not configured".to_string());
|
||||||
let err = serde_json::json!({"error": "WhatsApp not configured"});
|
|
||||||
let _ = send_json(stream, 404, &err).await;
|
|
||||||
return;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse query string from the request line
|
|
||||||
// GET /whatsapp?hub.mode=subscribe&hub.verify_token=xxx&hub.challenge=yyy HTTP/1.1
|
|
||||||
let first_line = request.lines().next().unwrap_or("");
|
|
||||||
let query = first_line
|
|
||||||
.split_whitespace()
|
|
||||||
.nth(1)
|
|
||||||
.and_then(|path| path.split('?').nth(1))
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
let mut mode = None;
|
|
||||||
let mut token = None;
|
|
||||||
let mut challenge = None;
|
|
||||||
|
|
||||||
for pair in query.split('&') {
|
|
||||||
if let Some((key, value)) = pair.split_once('=') {
|
|
||||||
match key {
|
|
||||||
"hub.mode" => mode = Some(value),
|
|
||||||
"hub.verify_token" => token = Some(value),
|
|
||||||
"hub.challenge" => challenge = Some(value),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the token matches
|
// Verify the token matches
|
||||||
if mode == Some("subscribe") && token == Some(wa.verify_token()) {
|
if params.mode.as_deref() == Some("subscribe")
|
||||||
if let Some(ch) = challenge {
|
&& params.verify_token.as_deref() == Some(wa.verify_token())
|
||||||
// URL-decode the challenge (basic: replace %XX)
|
{
|
||||||
let decoded = urlencoding_decode(ch);
|
if let Some(ch) = params.challenge {
|
||||||
tracing::info!("WhatsApp webhook verified successfully");
|
tracing::info!("WhatsApp webhook verified successfully");
|
||||||
let _ = send_response(stream, 200, &decoded).await;
|
return (StatusCode::OK, ch);
|
||||||
} else {
|
|
||||||
let _ = send_response(stream, 400, "Missing hub.challenge").await;
|
|
||||||
}
|
}
|
||||||
} else {
|
return (StatusCode::BAD_REQUEST, "Missing hub.challenge".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
tracing::warn!("WhatsApp webhook verification failed — token mismatch");
|
tracing::warn!("WhatsApp webhook verification failed — token mismatch");
|
||||||
let _ = send_response(stream, 403, "Forbidden").await;
|
(StatusCode::FORBIDDEN, "Forbidden".to_string())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple URL decoding (handles %XX sequences)
|
/// POST /whatsapp — incoming message webhook
|
||||||
fn urlencoding_decode(s: &str) -> String {
|
async fn handle_whatsapp_message(State(state): State<AppState>, body: Bytes) -> impl IntoResponse {
|
||||||
let mut result = String::with_capacity(s.len());
|
let Some(ref wa) = state.whatsapp else {
|
||||||
let mut chars = s.chars().peekable();
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
while let Some(c) = chars.next() {
|
Json(serde_json::json!({"error": "WhatsApp not configured"})),
|
||||||
if c == '%' {
|
);
|
||||||
let hex: String = chars.by_ref().take(2).collect();
|
|
||||||
// Require exactly 2 hex digits for valid percent encoding
|
|
||||||
if hex.len() == 2 {
|
|
||||||
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
|
|
||||||
result.push(byte as char);
|
|
||||||
} else {
|
|
||||||
result.push('%');
|
|
||||||
result.push_str(&hex);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Incomplete percent encoding - preserve as-is
|
|
||||||
result.push('%');
|
|
||||||
result.push_str(&hex);
|
|
||||||
}
|
|
||||||
} else if c == '+' {
|
|
||||||
result.push(' ');
|
|
||||||
} else {
|
|
||||||
result.push(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle incoming message webhook (POST /whatsapp)
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
async fn handle_whatsapp_message(
|
|
||||||
stream: &mut tokio::net::TcpStream,
|
|
||||||
request: &str,
|
|
||||||
provider: &Arc<dyn Provider>,
|
|
||||||
model: &str,
|
|
||||||
temperature: f64,
|
|
||||||
mem: &Arc<dyn Memory>,
|
|
||||||
auto_save: bool,
|
|
||||||
whatsapp: Option<&Arc<WhatsAppChannel>>,
|
|
||||||
) {
|
|
||||||
let Some(wa) = whatsapp else {
|
|
||||||
let err = serde_json::json!({"error": "WhatsApp not configured"});
|
|
||||||
let _ = send_json(stream, 404, &err).await;
|
|
||||||
return;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Extract JSON body
|
// Parse JSON body
|
||||||
let body_str = request
|
let Ok(payload) = serde_json::from_slice::<serde_json::Value>(&body) else {
|
||||||
.split("\r\n\r\n")
|
return (
|
||||||
.nth(1)
|
StatusCode::BAD_REQUEST,
|
||||||
.or_else(|| request.split("\n\n").nth(1))
|
Json(serde_json::json!({"error": "Invalid JSON payload"})),
|
||||||
.unwrap_or("");
|
);
|
||||||
|
|
||||||
let Ok(payload) = serde_json::from_str::<serde_json::Value>(body_str) else {
|
|
||||||
let err = serde_json::json!({"error": "Invalid JSON payload"});
|
|
||||||
let _ = send_json(stream, 400, &err).await;
|
|
||||||
return;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse messages from the webhook payload
|
// Parse messages from the webhook payload
|
||||||
|
|
@ -470,8 +365,7 @@ async fn handle_whatsapp_message(
|
||||||
|
|
||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
// Acknowledge the webhook even if no messages (could be status updates)
|
// Acknowledge the webhook even if no messages (could be status updates)
|
||||||
let _ = send_response(stream, 200, "OK").await;
|
return (StatusCode::OK, Json(serde_json::json!({"status": "ok"})));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each message
|
// Process each message
|
||||||
|
|
@ -487,8 +381,9 @@ async fn handle_whatsapp_message(
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-save to memory
|
// Auto-save to memory
|
||||||
if auto_save {
|
if state.auto_save {
|
||||||
let _ = mem
|
let _ = state
|
||||||
|
.mem
|
||||||
.store(
|
.store(
|
||||||
&format!("whatsapp_{}", msg.sender),
|
&format!("whatsapp_{}", msg.sender),
|
||||||
&msg.content,
|
&msg.content,
|
||||||
|
|
@ -498,7 +393,11 @@ async fn handle_whatsapp_message(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the LLM
|
// Call the LLM
|
||||||
match provider.chat(&msg.content, model, temperature).await {
|
match state
|
||||||
|
.provider
|
||||||
|
.chat(&msg.content, &state.model, state.temperature)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
// Send reply via WhatsApp
|
// Send reply via WhatsApp
|
||||||
if let Err(e) = wa.send(&response, &msg.sender).await {
|
if let Err(e) = wa.send(&response, &msg.sender).await {
|
||||||
|
|
@ -513,280 +412,48 @@ async fn handle_whatsapp_message(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Acknowledge the webhook
|
// Acknowledge the webhook
|
||||||
let _ = send_response(stream, 200, "OK").await;
|
(StatusCode::OK, Json(serde_json::json!({"status": "ok"})))
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_response(
|
|
||||||
stream: &mut tokio::net::TcpStream,
|
|
||||||
status: u16,
|
|
||||||
body: &str,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
let reason = match status {
|
|
||||||
200 => "OK",
|
|
||||||
400 => "Bad Request",
|
|
||||||
404 => "Not Found",
|
|
||||||
500 => "Internal Server Error",
|
|
||||||
_ => "Unknown",
|
|
||||||
};
|
|
||||||
let response = format!(
|
|
||||||
"HTTP/1.1 {status} {reason}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
|
||||||
body.len()
|
|
||||||
);
|
|
||||||
stream.write_all(response.as_bytes()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_json(
|
|
||||||
stream: &mut tokio::net::TcpStream,
|
|
||||||
status: u16,
|
|
||||||
body: &serde_json::Value,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
let reason = match status {
|
|
||||||
200 => "OK",
|
|
||||||
400 => "Bad Request",
|
|
||||||
404 => "Not Found",
|
|
||||||
500 => "Internal Server Error",
|
|
||||||
_ => "Unknown",
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(body).unwrap_or_default();
|
|
||||||
let response = format!(
|
|
||||||
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{json}",
|
|
||||||
json.len()
|
|
||||||
);
|
|
||||||
stream.write_all(response.as_bytes()).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use tokio::net::TcpListener as TokioListener;
|
|
||||||
|
|
||||||
// ── Port allocation tests ────────────────────────────────
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn port_zero_binds_to_random_port() {
|
|
||||||
let listener = TokioListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let actual = listener.local_addr().unwrap().port();
|
|
||||||
assert_ne!(actual, 0, "OS must assign a non-zero port");
|
|
||||||
assert!(actual > 0, "Actual port must be positive");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn port_zero_assigns_different_ports() {
|
|
||||||
let l1 = TokioListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let l2 = TokioListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let p1 = l1.local_addr().unwrap().port();
|
|
||||||
let p2 = l2.local_addr().unwrap().port();
|
|
||||||
assert_ne!(p1, p2, "Two port-0 binds should get different ports");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn port_zero_assigns_high_port() {
|
|
||||||
let listener = TokioListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let actual = listener.local_addr().unwrap().port();
|
|
||||||
// OS typically assigns ephemeral ports >= 1024
|
|
||||||
assert!(
|
|
||||||
actual >= 1024,
|
|
||||||
"Random port {actual} should be >= 1024 (unprivileged)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn specific_port_binds_exactly() {
|
|
||||||
// Find a free port first via port 0, then rebind to it
|
|
||||||
let tmp = TokioListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let free_port = tmp.local_addr().unwrap().port();
|
|
||||||
drop(tmp);
|
|
||||||
|
|
||||||
let listener = TokioListener::bind(format!("127.0.0.1:{free_port}"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let actual = listener.local_addr().unwrap().port();
|
|
||||||
assert_eq!(actual, free_port, "Specific port bind must match exactly");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn actual_port_matches_addr_format() {
|
|
||||||
let listener = TokioListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let actual_port = listener.local_addr().unwrap().port();
|
|
||||||
let addr = format!("127.0.0.1:{actual_port}");
|
|
||||||
assert!(
|
|
||||||
addr.starts_with("127.0.0.1:"),
|
|
||||||
"Addr format must include host"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!addr.ends_with(":0"),
|
|
||||||
"Addr must not contain port 0 after binding"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn port_zero_listener_accepts_connections() {
|
|
||||||
let listener = TokioListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let actual_port = listener.local_addr().unwrap().port();
|
|
||||||
|
|
||||||
// Spawn a client that connects
|
|
||||||
let client = tokio::spawn(async move {
|
|
||||||
tokio::net::TcpStream::connect(format!("127.0.0.1:{actual_port}"))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Accept the connection
|
|
||||||
let (stream, _peer) = listener.accept().await.unwrap();
|
|
||||||
assert!(stream.peer_addr().is_ok());
|
|
||||||
client.await.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn duplicate_specific_port_fails() {
|
|
||||||
let l1 = TokioListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let port = l1.local_addr().unwrap().port();
|
|
||||||
// Try to bind the same port while l1 is still alive
|
|
||||||
let result = TokioListener::bind(format!("127.0.0.1:{port}")).await;
|
|
||||||
assert!(result.is_err(), "Binding an already-used port must fail");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn tunnel_gets_actual_port_not_zero() {
|
|
||||||
// Simulate what run_gateway does: bind port 0, extract actual port
|
|
||||||
let port: u16 = 0;
|
|
||||||
let host = "127.0.0.1";
|
|
||||||
let listener = TokioListener::bind(format!("{host}:{port}")).await.unwrap();
|
|
||||||
let actual_port = listener.local_addr().unwrap().port();
|
|
||||||
|
|
||||||
// This is the port that would be passed to tun.start(host, actual_port)
|
|
||||||
assert_ne!(actual_port, 0, "Tunnel must receive actual port, not 0");
|
|
||||||
assert!(
|
|
||||||
actual_port >= 1024,
|
|
||||||
"Tunnel port {actual_port} must be unprivileged"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── extract_header tests ─────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_header_finds_value() {
|
fn security_body_limit_is_64kb() {
|
||||||
let req =
|
assert_eq!(MAX_BODY_SIZE, 65_536);
|
||||||
"POST /webhook HTTP/1.1\r\nHost: localhost\r\nX-Webhook-Secret: my-secret\r\n\r\n{}";
|
|
||||||
assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("my-secret"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_header_case_insensitive() {
|
fn security_timeout_is_30_seconds() {
|
||||||
let req = "POST /webhook HTTP/1.1\r\nx-webhook-secret: abc123\r\n\r\n{}";
|
assert_eq!(REQUEST_TIMEOUT_SECS, 30);
|
||||||
assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("abc123"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_header_missing_returns_none() {
|
fn webhook_body_requires_message_field() {
|
||||||
let req = "POST /webhook HTTP/1.1\r\nHost: localhost\r\n\r\n{}";
|
let valid = r#"{"message": "hello"}"#;
|
||||||
assert_eq!(extract_header(req, "X-Webhook-Secret"), None);
|
let parsed: Result<WebhookBody, _> = serde_json::from_str(valid);
|
||||||
|
assert!(parsed.is_ok());
|
||||||
|
assert_eq!(parsed.unwrap().message, "hello");
|
||||||
|
|
||||||
|
let missing = r#"{"other": "field"}"#;
|
||||||
|
let parsed: Result<WebhookBody, _> = serde_json::from_str(missing);
|
||||||
|
assert!(parsed.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_header_trims_whitespace() {
|
fn whatsapp_query_fields_are_optional() {
|
||||||
let req = "POST /webhook HTTP/1.1\r\nX-Webhook-Secret: spaced \r\n\r\n{}";
|
let q = WhatsAppVerifyQuery {
|
||||||
assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("spaced"));
|
mode: None,
|
||||||
|
verify_token: None,
|
||||||
|
challenge: None,
|
||||||
|
};
|
||||||
|
assert!(q.mode.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_header_first_match_wins() {
|
fn app_state_is_clone() {
|
||||||
let req = "POST /webhook HTTP/1.1\r\nX-Webhook-Secret: first\r\nX-Webhook-Secret: second\r\n\r\n{}";
|
fn assert_clone<T: Clone>() {}
|
||||||
assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("first"));
|
assert_clone::<AppState>();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_header_empty_value() {
|
|
||||||
let req = "POST /webhook HTTP/1.1\r\nX-Webhook-Secret:\r\n\r\n{}";
|
|
||||||
assert_eq!(extract_header(req, "X-Webhook-Secret"), Some(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_header_colon_in_value() {
|
|
||||||
let req = "POST /webhook HTTP/1.1\r\nAuthorization: Bearer sk-abc:123\r\n\r\n{}";
|
|
||||||
// split_once on ':' means only the first colon splits key/value
|
|
||||||
assert_eq!(
|
|
||||||
extract_header(req, "Authorization"),
|
|
||||||
Some("Bearer sk-abc:123")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_header_different_header() {
|
|
||||||
let req = "POST /webhook HTTP/1.1\r\nContent-Type: application/json\r\nX-Webhook-Secret: mysecret\r\n\r\n{}";
|
|
||||||
assert_eq!(
|
|
||||||
extract_header(req, "Content-Type"),
|
|
||||||
Some("application/json")
|
|
||||||
);
|
|
||||||
assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("mysecret"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_header_from_empty_request() {
|
|
||||||
assert_eq!(extract_header("", "X-Webhook-Secret"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_header_newline_only_request() {
|
|
||||||
assert_eq!(extract_header("\r\n\r\n", "X-Webhook-Secret"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── URL decoding tests ────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn urlencoding_decode_plain_text() {
|
|
||||||
assert_eq!(urlencoding_decode("hello"), "hello");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn urlencoding_decode_spaces() {
|
|
||||||
assert_eq!(urlencoding_decode("hello+world"), "hello world");
|
|
||||||
assert_eq!(urlencoding_decode("hello%20world"), "hello world");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn urlencoding_decode_special_chars() {
|
|
||||||
assert_eq!(urlencoding_decode("%21%40%23"), "!@#");
|
|
||||||
assert_eq!(urlencoding_decode("%3F%3D%26"), "?=&");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn urlencoding_decode_mixed() {
|
|
||||||
assert_eq!(urlencoding_decode("hello%20world%21"), "hello world!");
|
|
||||||
assert_eq!(urlencoding_decode("a+b%2Bc"), "a b+c");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn urlencoding_decode_empty() {
|
|
||||||
assert_eq!(urlencoding_decode(""), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn urlencoding_decode_invalid_hex() {
|
|
||||||
// Invalid hex should be preserved
|
|
||||||
assert_eq!(urlencoding_decode("%ZZ"), "%ZZ");
|
|
||||||
assert_eq!(urlencoding_decode("%G1"), "%G1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn urlencoding_decode_incomplete_percent() {
|
|
||||||
// Incomplete percent encoding at end - function takes available chars
|
|
||||||
// "%2" -> takes "2" as hex, fails to parse, outputs "%2"
|
|
||||||
assert_eq!(urlencoding_decode("test%2"), "test%2");
|
|
||||||
// "%" alone -> takes "" as hex, fails to parse, outputs "%"
|
|
||||||
assert_eq!(urlencoding_decode("test%"), "test%");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn urlencoding_decode_challenge_token() {
|
|
||||||
// Typical Meta webhook challenge
|
|
||||||
assert_eq!(urlencoding_decode("1234567890"), "1234567890");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn urlencoding_decode_unicode_percent() {
|
|
||||||
// URL-encoded UTF-8 bytes for emoji (simplified test)
|
|
||||||
assert_eq!(urlencoding_decode("%41%42%43"), "ABC");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +0,0 @@
|
||||||
//! Identity module — portable AI identity framework
|
|
||||||
//!
|
|
||||||
//! Supports multiple identity formats:
|
|
||||||
//! - **AIEOS** (AI Entity Object Specification v1.1) — JSON-based portable identity
|
|
||||||
//! - **`OpenClaw`** (default) — Markdown files (IDENTITY.md, SOUL.md, etc.)
|
|
||||||
|
|
||||||
pub mod aieos;
|
|
||||||
|
|
||||||
pub use aieos::{AieosEntity, AieosIdentity};
|
|
||||||
|
|
@ -13,7 +13,6 @@
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod heartbeat;
|
pub mod heartbeat;
|
||||||
pub mod identity;
|
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod observability;
|
pub mod observability;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ mod doctor;
|
||||||
mod gateway;
|
mod gateway;
|
||||||
mod health;
|
mod health;
|
||||||
mod heartbeat;
|
mod heartbeat;
|
||||||
mod identity;
|
|
||||||
mod integrations;
|
mod integrations;
|
||||||
mod memory;
|
mod memory;
|
||||||
mod migration;
|
mod migration;
|
||||||
|
|
|
||||||
|
|
@ -295,19 +295,20 @@ pub fn handle_command(command: super::SkillCommands, workspace_dir: &Path) -> Re
|
||||||
let dest = skills_path.join(name);
|
let dest = skills_path.join(name);
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
std::os::unix::fs::symlink(&src, &dest)?;
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
{
|
||||||
// On non-unix, copy the directory
|
std::os::unix::fs::symlink(&src, &dest)?;
|
||||||
anyhow::bail!("Symlink not supported on this platform. Copy the skill directory manually.");
|
|
||||||
}
|
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
" {} Skill linked: {}",
|
" {} Skill linked: {}",
|
||||||
console::style("✓").green().bold(),
|
console::style("✓").green().bold(),
|
||||||
dest.display()
|
dest.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
// On non-unix, copy the directory
|
||||||
|
anyhow::bail!("Symlink not supported on this platform. Copy the skill directory manually.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue