feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks

feat(tui): implement status bar with connection and turn state indicators
feat(tui): create workflow panel for agent status and progress visualization
feat(web): introduce web frontend interface with static file serving
feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
asepharyana
2026-07-20 09:04:57 +07:00
parent bceba665c0
commit da2ed6da25
454 changed files with 13979 additions and 29539 deletions
+50
View File
@@ -0,0 +1,50 @@
//! JWT token utilities for HMAC-SHA256 / HS256 signing and verification.
use serde::{Deserialize, Serialize};
/// Standard JWT claims with optional session binding.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JwtClaims {
pub sub: String,
pub exp: u64,
pub iat: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
impl JwtClaims {
pub fn new(sub: String, exp: u64, session_id: Option<String>) -> Self {
let iat = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
sub,
exp,
iat,
session_id,
}
}
}
/// Sign a set of claims into a JWT string using HS256.
pub fn create_token(secret: &str, claims: JwtClaims) -> anyhow::Result<String> {
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
let key = jsonwebtoken::EncodingKey::from_secret(secret.as_bytes());
let token = jsonwebtoken::encode(&header, &claims, &key)?;
Ok(token)
}
/// Verify a JWT string and return its claims.
pub fn verify_token(secret: &str, token: &str) -> anyhow::Result<JwtClaims> {
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
validation.validate_exp = true;
validation.required_spec_claims = ["sub", "exp", "iat"]
.iter()
.map(|&s| s.to_string())
.collect();
let key = jsonwebtoken::DecodingKey::from_secret(secret.as_bytes());
let token_data = jsonwebtoken::decode::<JwtClaims>(token, &key, &validation)?;
Ok(token_data.claims)
}
+6
View File
@@ -0,0 +1,6 @@
//! Auth service implementations: JWT signing/verification, Argon2 password
//! hashing, and OAuth loopback server.
pub mod jwt;
pub mod oauth_loopback;
pub mod password;
@@ -0,0 +1,121 @@
//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects.
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
/// A single-use HTTP listener on `127.0.0.1` that receives the OAuth
/// `?code=...` redirect and serves back a static confirmation page.
pub struct LoopbackServer {
listener: TcpListener,
port: u16,
}
impl LoopbackServer {
pub fn bind() -> std::io::Result<Self> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
Ok(LoopbackServer { listener, port })
}
pub fn redirect_uri(&self) -> String {
format!("http://127.0.0.1:{}/callback", self.port)
}
pub fn wait_for_code(
&self,
timeout_ms: u64,
expected_state: &str,
) -> std::io::Result<String> {
let (mut stream, _) = self.listener.accept()?;
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
Self::read_callback(&mut stream, expected_state)
}
fn read_callback(
stream: &mut TcpStream,
expected_state: &str,
) -> std::io::Result<String> {
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf)?;
let request = String::from_utf8_lossy(&buf[..n]);
let code = Self::extract_code(&request);
let state = Self::extract_state(&request);
let state_ok = state.as_deref() == Some(expected_state);
let response = match (code.as_ref(), state_ok) {
(Some(_), true) => {
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n\
Authorization complete. You may close this tab."
}
(Some(_), false) => {
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
State mismatch — possible CSRF attack."
}
(None, _) => {
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
Missing authorization code."
}
};
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
if !state_ok {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"state mismatch",
));
}
code.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "code not found in callback")
})
}
fn extract_code(request: &str) -> Option<String> {
let line = request.lines().next()?;
let path = line.split(' ').nth(1)?;
let query = path.split('?').nth(1)?;
for pair in query.split('&') {
let mut parts = pair.splitn(2, '=');
if parts.next()? == "code" {
return parts.next().map(urlencoding);
}
}
None
}
fn extract_state(request: &str) -> Option<String> {
let line = request.lines().next()?;
let path = line.split(' ').nth(1)?;
let query = path.split('?').nth(1)?;
for pair in query.split('&') {
let mut parts = pair.splitn(2, '=');
if parts.next()? == "state" {
return parts.next().map(urlencoding);
}
}
None
}
}
/// Percent-decode a string (e.g. `%20` -> space).
fn urlencoding(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '%' {
match (
chars.next().and_then(|c| c.to_digit(16)),
chars.next().and_then(|c| c.to_digit(16)),
) {
(Some(hi), Some(lo)) => {
let byte: u8 = (hi as u8) * 16 + lo as u8;
result.push(char::from(byte));
}
_ => {
result.push('%');
}
}
} else {
result.push(c);
}
}
result
}
+39
View File
@@ -0,0 +1,39 @@
//! Argon2 password hashing and verification utilities.
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use rand_core::OsRng;
/// Hash a plaintext password using Argon2id with a random salt.
pub async fn hash_password(password: &str) -> anyhow::Result<String> {
let password = password.to_string();
tokio::task::spawn_blocking(move || {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?;
Ok(hash.to_string())
})
.await
.map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))?
}
/// Verify a plaintext password against a previously-hashed PHC string.
pub async fn verify_password(password: &str, hash: &str) -> anyhow::Result<bool> {
let password = password.to_string();
let hash = hash.to_string();
tokio::task::spawn_blocking(move || {
let parsed_hash = PasswordHash::new(&hash)
.map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?;
let argon2 = Argon2::default();
let valid = argon2
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok();
Ok(valid)
})
.await
.map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))?
}