diff --git a/src/auto_task/agent_executor.rs b/src/auto_task/agent_executor.rs new file mode 100644 index 000000000..747f9e790 --- /dev/null +++ b/src/auto_task/agent_executor.rs @@ -0,0 +1,114 @@ +use crate::auto_task::container_session::{ContainerSession, TerminalOutput}; +use crate::core::shared::state::{AppState, TaskProgressEvent}; +use log::error; +use std::sync::Arc; +use tokio::sync::mpsc; + +pub struct AgentExecutor { + pub state: Arc, + pub session_id: String, + pub task_id: String, + container: Option, +} + +impl AgentExecutor { + pub fn new(state: Arc, session_id: &str, task_id: &str) -> Self { + Self { + state, + session_id: session_id.to_string(), + task_id: task_id.to_string(), + container: None, + } + } + + pub async fn initialize(&mut self) -> Result<(), String> { + self.broadcast_step("Initializing Agent Environment", 1, 10); + + let mut session = ContainerSession::new(&self.session_id).await?; + let (tx, mut rx) = mpsc::channel(100); + + session.start_terminal(tx).await?; + self.container = Some(session); + + // Spawn a task to listen to terminal output and broadcast it + let state_clone = self.state.clone(); + let task_id_clone = self.task_id.clone(); + + tokio::spawn(async move { + while let Some(output) = rx.recv().await { + let (line, stream) = match output { + TerminalOutput::Stdout(l) => (l, "stdout"), + TerminalOutput::Stderr(l) => (l, "stderr"), + }; + + let mut event = TaskProgressEvent::new( + &task_id_clone, + "terminal_output", + &line, + ) + .with_event_type("terminal_output"); + // The JS on the frontend expects { type: "terminal_output", line: "...", stream: "..." } + // So we hijack details for stream + event.details = Some(stream.to_string()); + event.text = Some(line.clone()); + + state_clone.broadcast_task_progress(event); + } + }); + + self.broadcast_browser_ready("http://localhost:8000", 8000); + self.broadcast_step("Agent Ready", 2, 10); + + Ok(()) + } + + pub async fn execute_shell_command(&mut self, cmd: &str) -> Result<(), String> { + if let Some(container) = &mut self.container { + container.send_command(cmd).await?; + Ok(()) + } else { + Err("Container not initialized".into()) + } + } + + pub fn broadcast_thought(&self, thought: &str) { + let mut event = TaskProgressEvent::new( + &self.task_id, + "thought_process", + thought, + ) + .with_event_type("thought_process"); + event.text = Some(thought.to_string()); + self.state.broadcast_task_progress(event); + } + + pub fn broadcast_step(&self, label: &str, current: u8, total: u8) { + let event = TaskProgressEvent::new( + &self.task_id, + "step_progress", + label, + ) + .with_event_type("step_progress") + .with_progress(current, total); + self.state.broadcast_task_progress(event); + } + + pub fn broadcast_browser_ready(&self, url: &str, port: u16) { + let mut event = TaskProgressEvent::new( + &self.task_id, + "browser_ready", + url, + ) + .with_event_type("browser_ready"); + event.details = Some(port.to_string()); + self.state.broadcast_task_progress(event); + } + + pub async fn cleanup(&mut self) { + if let Some(mut container) = self.container.take() { + if let Err(e) = container.stop().await { + error!("Error stopping container session: {}", e); + } + } + } +} diff --git a/src/auto_task/container_session.rs b/src/auto_task/container_session.rs new file mode 100644 index 000000000..25049e35f --- /dev/null +++ b/src/auto_task/container_session.rs @@ -0,0 +1,137 @@ +use log::{info, warn}; +use std::process::Stdio; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, Command}; +use tokio::sync::{mpsc, Mutex}; + +#[derive(Debug)] +pub enum TerminalOutput { + Stdout(String), + Stderr(String), +} + +pub struct ContainerSession { + pub session_id: String, + pub container_name: String, + process: Option, + stdin: Option>>, +} + +impl ContainerSession { + pub async fn new(session_id: &str) -> Result { + let container_name = format!("agent-{}", session_id.chars().take(8).collect::()); + + // Launch the container (this might take a moment if the image isn't cached locally) + info!("Launching LXC container: {}", container_name); + let launch_status = Command::new("lxc") + .args(&["launch", "ubuntu:22.04", &container_name]) + .output() + .await + .map_err(|e| format!("Failed to execute lxc launch: {}", e))?; + + if !launch_status.status.success() { + let stderr = String::from_utf8_lossy(&launch_status.stderr); + // If it already exists, that's fine, we can just use it + if !stderr.contains("already exists") { + warn!("Warning during LXC launch (might already exist): {}", stderr); + } + } + + Ok(Self { + session_id: session_id.to_string(), + container_name, + process: None, + stdin: None, + }) + } + + pub async fn start_terminal(&mut self, tx: mpsc::Sender) -> Result<(), String> { + info!("Starting terminal session in container: {}", self.container_name); + + let mut child = Command::new("lxc") + .args(&["exec", &self.container_name, "--", "bash"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to spawn lxc exec: {}", e))?; + + let stdin = child.stdin.take().ok_or("Failed to capture stdin")?; + let stdout = child.stdout.take().ok_or("Failed to capture stdout")?; + let stderr = child.stderr.take().ok_or("Failed to capture stderr")?; + + self.stdin = Some(Arc::new(Mutex::new(stdin))); + self.process = Some(child); + + // Spawn stdout reader + let tx_out = tx.clone(); + tokio::spawn(async move { + let mut reader = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = reader.next_line().await { + if tx_out.send(TerminalOutput::Stdout(line)).await.is_err() { + break; + } + } + }); + + // Spawn stderr reader + let tx_err = tx; + tokio::spawn(async move { + let mut reader = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = reader.next_line().await { + if tx_err.send(TerminalOutput::Stderr(line)).await.is_err() { + break; + } + } + }); + + // Send a setup command to get things ready + self.send_command("export TERM=xterm-256color; cd /root").await?; + + Ok(()) + } + + pub async fn send_command(&self, cmd: &str) -> Result<(), String> { + if let Some(stdin_mutex) = &self.stdin { + let mut stdin = stdin_mutex.lock().await; + let cmd_with_newline = format!("{}\n", cmd); + stdin.write_all(cmd_with_newline.as_bytes()).await + .map_err(|e| format!("Failed to write to stdin: {}", e))?; + stdin.flush().await + .map_err(|e| format!("Failed to flush stdin: {}", e))?; + Ok(()) + } else { + Err("Terminal not started".to_string()) + } + } + + pub async fn stop(&mut self) -> Result<(), String> { + info!("Stopping container session: {}", self.container_name); + + if let Some(mut child) = self.process.take() { + let _ = child.kill().await; + } + + // Clean up container + let status = Command::new("lxc") + .args(&["delete", &self.container_name, "--force"]) + .output() + .await + .map_err(|e| format!("Failed to delete container: {}", e))?; + + if !status.status.success() { + warn!("Failed to delete container {}: {}", self.container_name, String::from_utf8_lossy(&status.stderr)); + } + + Ok(()) + } +} + +impl Drop for ContainerSession { + fn drop(&mut self) { + // We can't easily await inside drop, but the actual LXC container persists + // unless we spawn a blocking task or fire-and-forget task to delete it. + // For reliability, we expect the caller to call `stop().await`. + } +} diff --git a/src/auto_task/intent_classifier.rs b/src/auto_task/intent_classifier.rs index 26be8a312..913df34bf 100644 --- a/src/auto_task/intent_classifier.rs +++ b/src/auto_task/intent_classifier.rs @@ -514,6 +514,17 @@ Respond with JSON only: ) -> Result> { info!("Handling APP_CREATE intent"); + // [AGENT MODE] Initialize the LXC container session for real terminal output + let t_id = task_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let mut executor = crate::auto_task::AgentExecutor::new(self.state.clone(), &session.id.to_string(), &t_id); + + if let Err(e) = executor.initialize().await { + log::warn!("Failed to initialize LXC container for agent: {}", e); + } else { + executor.broadcast_thought("Analyzing the user prompt and setting up a dedicated LXC workspace..."); + let _ = executor.execute_shell_command("echo 'Initializing Agent Workspace...' && date && mkdir -p /root/app").await; + } + let mut app_generator = if let Some(tid) = task_id { AppGenerator::with_task_id(self.state.clone(), tid) } else { @@ -554,7 +565,7 @@ Respond with JSON only: let app_url = format!("/apps/{}", app.name.to_lowercase().replace(' ', "-")); - Ok(IntentResult { + let res = Ok(IntentResult { success: true, intent_type: IntentType::AppCreate, message: format!( @@ -577,11 +588,17 @@ Respond with JSON only: "Use Designer to customize the app".to_string(), ], error: None, - }) + }); + + // [AGENT MODE] Cleanup the LXC container + executor.broadcast_thought("Generation complete. Terminating LXC sandbox."); + executor.cleanup().await; + + res } Err(e) => { error!("Failed to generate app: {e}"); - Ok(IntentResult { + let res = Ok(IntentResult { success: false, intent_type: IntentType::AppCreate, message: "Failed to create the application".to_string(), @@ -592,7 +609,13 @@ Respond with JSON only: tool_triggers: Vec::new(), next_steps: vec!["Try again with more details".to_string()], error: Some(e.to_string()), - }) + }); + + // [AGENT MODE] Cleanup the LXC container on error + if let Err(_) = executor.execute_shell_command("echo 'Build failed' >&2").await {} + executor.cleanup().await; + + res } } } diff --git a/src/auto_task/mod.rs b/src/auto_task/mod.rs index d950ab501..52ea2dde2 100644 --- a/src/auto_task/mod.rs +++ b/src/auto_task/mod.rs @@ -8,6 +8,8 @@ pub mod intent_compiler; pub mod safety_layer; pub mod task_manifest; pub mod task_types; +pub mod agent_executor; +pub mod container_session; pub use app_generator::{ AppGenerator, AppStructure, FileType, GeneratedApp, GeneratedFile, GeneratedPage, PageType, @@ -38,6 +40,8 @@ pub use task_types::{AutoTask, AutoTaskStatus, ExecutionMode, TaskPriority}; pub use intent_classifier::{ClassifiedIntent, IntentClassifier, IntentType}; pub use intent_compiler::{CompiledIntent, IntentCompiler}; pub use safety_layer::{AuditEntry, ConstraintCheckResult, SafetyLayer, SimulationResult}; +pub use agent_executor::*; +pub use container_session::*; use crate::core::urls::ApiUrls; use crate::core::shared::state::AppState; diff --git a/src/core/bootstrap/bootstrap_manager.rs b/src/core/bootstrap/bootstrap_manager.rs index b2c12e3d0..d1a7337bf 100644 --- a/src/core/bootstrap/bootstrap_manager.rs +++ b/src/core/bootstrap/bootstrap_manager.rs @@ -1,6 +1,6 @@ // Bootstrap manager implementation use crate::core::bootstrap::bootstrap_types::{BootstrapManager, BootstrapProgress}; -use crate::core::bootstrap::bootstrap_utils::{cache_health_check, safe_pkill, vault_health_check, vector_db_health_check}; +use crate::core::bootstrap::bootstrap_utils::{cache_health_check, safe_pkill, vault_health_check, vector_db_health_check, zitadel_health_check}; use crate::core::config::AppConfig; use crate::core::package_manager::{InstallMode, PackageManager}; use log::{info, warn}; @@ -158,6 +158,76 @@ impl BootstrapManager { } } + if pm.is_installed("directory") { + // Wait for Zitadel to be ready - it might have been started during installation + // Give it up to 60 seconds before trying to start it ourselves + let mut directory_already_running = zitadel_health_check(); + if !directory_already_running { + info!("Zitadel not responding to health check, waiting up to 60s for it to start..."); + for i in 0..30 { + sleep(Duration::from_secs(2)).await; + if zitadel_health_check() { + info!("Zitadel/Directory service is now responding (waited {}s)", (i + 1) * 2); + directory_already_running = true; + break; + } + } + } + + if directory_already_running { + info!("Zitadel/Directory service is already running"); + } else { + info!("Starting Zitadel/Directory service..."); + match pm.start("directory") { + Ok(_child) => { + info!("Directory service started, waiting for readiness..."); + let mut zitadel_ready = false; + for i in 0..150 { + sleep(Duration::from_secs(2)).await; + if zitadel_health_check() { + info!("Zitadel/Directory service is responding"); + zitadel_ready = true; + break; + } + if i == 149 { + warn!("Zitadel/Directory service did not respond after 300 seconds"); + } + } + + // Create OAuth client if Zitadel is ready and config doesn't exist + if zitadel_ready { + let config_path = self.stack_dir("conf/system/directory_config.json"); + if !config_path.exists() { + info!("Creating OAuth client for Directory service..."); + match crate::core::package_manager::setup_directory().await { + Ok(_) => info!("OAuth client created successfully"), + Err(e) => warn!("Failed to create OAuth client: {}", e), + } + } + } + } + Err(e) => { + warn!("Failed to start Directory service: {}", e); + } + } + } + + // Note: Directory (Zitadel) bootstrap is handled in main_module/bootstrap.rs + // where it has proper access to the admin PAT token + } + + if pm.is_installed("alm") { + info!("Starting ALM (Forgejo) service..."); + match pm.start("alm") { + Ok(_child) => { + info!("ALM service started"); + } + Err(e) => { + warn!("Failed to start ALM service: {}", e); + } + } + } + // Caddy is the web server match Command::new("caddy") .arg("validate") @@ -209,7 +279,7 @@ impl BootstrapManager { } // Install other core components (names must match 3rdparty.toml) - let core_components = ["tables", "cache", "drive", "llm"]; + let core_components = ["tables", "cache", "drive", "directory", "llm"]; for component in core_components { if !pm.is_installed(component) { info!("Installing {}...", component); diff --git a/src/core/package_manager/mod.rs b/src/core/package_manager/mod.rs index ab7a2553a..e5a846f65 100644 --- a/src/core/package_manager/mod.rs +++ b/src/core/package_manager/mod.rs @@ -44,3 +44,145 @@ pub fn get_all_components() -> Vec { }, ] } + +/// Parse Zitadel log file to extract initial admin credentials +#[cfg(feature = "directory")] +fn extract_initial_admin_from_log(log_path: &std::path::Path) -> Option<(String, String)> { + use std::fs; + + let log_content = fs::read_to_string(log_path).ok()?; + + // Try different log formats from Zitadel + // Format 1: "initial admin user created. email: admin@ password: " + for line in log_content.lines() { + let line_lower = line.to_lowercase(); + if line_lower.contains("initial admin") || line_lower.contains("admin credentials") { + // Try to extract email and password + let email = if let Some(email_start) = line.find("email:") { + let rest = &line[email_start + 6..]; + rest.trim() + .split_whitespace() + .next() + .map(|s| s.trim_end_matches(',').to_string()) + } else if let Some(email_start) = line.find("Email:") { + let rest = &line[email_start + 6..]; + rest.trim() + .split_whitespace() + .next() + .map(|s| s.trim_end_matches(',').to_string()) + } else { + None + }; + + let password = if let Some(pwd_start) = line.find("password:") { + let rest = &line[pwd_start + 9..]; + rest.trim() + .split_whitespace() + .next() + .map(|s| s.trim_end_matches(',').to_string()) + } else if let Some(pwd_start) = line.find("Password:") { + let rest = &line[pwd_start + 9..]; + rest.trim() + .split_whitespace() + .next() + .map(|s| s.trim_end_matches(',').to_string()) + } else { + None + }; + + if let (Some(email), Some(password)) = (email, password) { + if !email.is_empty() && !password.is_empty() { + log::info!("Extracted initial admin credentials from log: {}", email); + return Some((email, password)); + } + } + } + } + + // Try multiline format + // Admin credentials: + // Email: admin@localhost + // Password: xxxxx + let lines: Vec<&str> = log_content.lines().collect(); + for i in 0..lines.len().saturating_sub(2) { + if lines[i].to_lowercase().contains("admin credentials") { + let mut email = None; + let mut password = None; + + for j in (i + 1)..std::cmp::min(i + 5, lines.len()) { + let line = lines[j]; + if line.contains("Email:") { + email = line.split("Email:") + .nth(1) + .map(|s| s.trim().to_string()); + } + if line.contains("Password:") { + password = line.split("Password:") + .nth(1) + .map(|s| s.trim().to_string()); + } + } + + if let (Some(e), Some(p)) = (email, password) { + if !e.is_empty() && !p.is_empty() { + log::info!("Extracted initial admin credentials from multiline log: {}", e); + return Some((e, p)); + } + } + } + } + + None +} + +/// Initialize Directory (Zitadel) with default admin user and OAuth application +/// This should be called after Zitadel has started and is responding +#[cfg(feature = "directory")] +pub async fn setup_directory() -> anyhow::Result { + use std::path::PathBuf; + + let stack_path = std::env::var("BOTSERVER_STACK_PATH") + .unwrap_or_else(|_| "./botserver-stack".to_string()); + + let base_url = "http://localhost:8300".to_string(); + let config_path = PathBuf::from(&stack_path).join("conf/system/directory_config.json"); + + // Check if config already exists + if config_path.exists() { + if let Ok(content) = std::fs::read_to_string(&config_path) { + if let Ok(config) = serde_json::from_str::(&content) { + if !config.client_id.is_empty() && !config.client_secret.is_empty() { + log::info!("Directory already configured with OAuth client"); + return Ok(config); + } + } + } + } + + // Try to get initial admin credentials from Zitadel log + let log_path = PathBuf::from(&stack_path).join("logs/zitadel.log"); + let admin_credentials = extract_initial_admin_from_log(&log_path); + + let mut directory_setup = if let Some((email, password)) = admin_credentials { + log::info!("Using initial admin credentials from log for OAuth client creation"); + crate::core::package_manager::setup::DirectorySetup::with_admin_credentials( + base_url, + config_path.clone(), + email, + password, + ) + } else { + log::warn!( + "Could not extract initial admin credentials from Zitadel log at {}. \ + OAuth client creation may fail. Check if Zitadel has started properly.", + log_path.display() + ); + crate::core::package_manager::setup::DirectorySetup::new( + base_url, + config_path.clone() + ) + }; + + directory_setup.initialize().await + .map_err(|e| anyhow::anyhow!("Failed to initialize directory: {}", e)) +} diff --git a/src/core/package_manager/setup/directory_setup.rs b/src/core/package_manager/setup/directory_setup.rs index 4fda55020..fbfa15dfc 100644 --- a/src/core/package_manager/setup/directory_setup.rs +++ b/src/core/package_manager/setup/directory_setup.rs @@ -12,6 +12,8 @@ pub struct DirectorySetup { base_url: String, client: Client, admin_token: Option, + /// Admin credentials for password grant authentication (used during initial setup) + admin_credentials: Option<(String, String)>, config_path: PathBuf, } @@ -20,9 +22,57 @@ impl DirectorySetup { self.admin_token = Some(token); } + /// Set admin credentials for password grant authentication + pub fn set_admin_credentials(&mut self, username: String, password: String) { + self.admin_credentials = Some((username, password)); + } + + /// Get an access token using either PAT or password grant + async fn get_admin_access_token(&self) -> Result { + // If we have a PAT token, use it directly + if let Some(ref token) = self.admin_token { + return Ok(token.clone()); + } + + // If we have admin credentials, use password grant + if let Some((username, password)) = &self.admin_credentials { + let token_url = format!("{}/oauth/v2/token", self.base_url); + let params = [ + ("grant_type", "password".to_string()), + ("username", username.clone()), + ("password", password.clone()), + ("scope", "openid profile email urn:zitadel:iam:org:project:id:zitadel:aud".to_string()), + ]; + + let response = self + .client + .post(&token_url) + .form(¶ms) + .send() + .await + .map_err(|e| anyhow::anyhow!("Failed to get access token: {}", e))?; + + let token_data: serde_json::Value = response + .json() + .await + .map_err(|e| anyhow::anyhow!("Failed to parse token response: {}", e))?; + + let access_token = token_data + .get("access_token") + .and_then(|t| t.as_str()) + .ok_or_else(|| anyhow::anyhow!("No access token in response"))? + .to_string(); + + log::info!("Obtained access token via password grant"); + return Ok(access_token); + } + + Err(anyhow::anyhow!("No admin token or credentials configured")) + } + pub fn ensure_admin_token(&mut self) -> Result<()> { - if self.admin_token.is_none() { - return Err(anyhow::anyhow!("Admin token must be configured")); + if self.admin_token.is_none() && self.admin_credentials.is_none() { + return Err(anyhow::anyhow!("Admin token or credentials must be configured")); } Ok(()) } @@ -90,6 +140,24 @@ impl DirectorySetup { Client::new() }), admin_token: None, + admin_credentials: None, + config_path, + } + } + + /// Create a DirectorySetup with initial admin credentials for password grant + pub fn with_admin_credentials(base_url: String, config_path: PathBuf, username: String, password: String) -> Self { + Self { + base_url, + client: Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .unwrap_or_else(|e| { + log::warn!("Failed to create HTTP client with timeout: {}, using default", e); + Client::new() + }), + admin_token: None, + admin_credentials: Some((username, password)), config_path, } } @@ -132,6 +200,10 @@ impl DirectorySetup { self.wait_for_ready(30).await?; + // Wait additional time for Zitadel API to be fully ready + log::info!("Waiting for Zitadel API to be fully initialized..."); + sleep(Duration::from_secs(10)).await; + self.ensure_admin_token()?; let org = self.create_default_organization().await?; @@ -140,7 +212,39 @@ impl DirectorySetup { let user = self.create_default_user(&org.id).await?; log::info!(" Created default user: {}", user.username); - let (project_id, client_id, client_secret) = self.create_oauth_application(&org.id).await?; + // Retry OAuth client creation up to 3 times with delays + let (project_id, client_id, client_secret) = { + let mut last_error = None; + let mut result = None; + + for attempt in 1..=3 { + match self.create_oauth_application(&org.id).await { + Ok(credentials) => { + result = Some(credentials); + break; + } + Err(e) => { + log::warn!( + "OAuth client creation attempt {}/3 failed: {}", + attempt, + e + ); + last_error = Some(e); + if attempt < 3 { + log::info!("Retrying in 5 seconds..."); + sleep(Duration::from_secs(5)).await; + } + } + } + } + + result.ok_or_else(|| { + anyhow::anyhow!( + "Failed to create OAuth client after 3 attempts: {}", + last_error.unwrap_or_else(|| anyhow::anyhow!("Unknown error")) + ) + })? + }; log::info!(" Created OAuth2 application"); self.grant_user_permissions(&org.id, &user.id).await?; @@ -337,38 +441,56 @@ impl DirectorySetup { _org_id: &str, ) -> Result<(String, String, String)> { let app_name = "BotServer"; - let redirect_uri = "http://localhost:9000/auth/callback".to_string(); + let redirect_uri = "http://localhost:8080/auth/callback".to_string(); + + // Get access token using either PAT or password grant + let access_token = self.get_admin_access_token().await + .map_err(|e| anyhow::anyhow!("Failed to get admin access token: {}", e))?; let project_response = self .client .post(format!("{}/management/v1/projects", self.base_url)) - .bearer_auth(self.admin_token.as_ref().unwrap_or(&String::new())) + .bearer_auth(&access_token) .json(&json!({ "name": app_name, })) .send() .await?; + if !project_response.status().is_success() { + let error_text = project_response.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!("Failed to create project: {}", error_text)); + } + let project_result: serde_json::Value = project_response.json().await?; let project_id = project_result["id"].as_str().unwrap_or("").to_string(); + if project_id.is_empty() { + return Err(anyhow::anyhow!("Project ID is empty in response")); + } + let app_response = self.client .post(format!("{}/management/v1/projects/{}/apps/oidc", self.base_url, project_id)) - .bearer_auth(self.admin_token.as_ref().unwrap_or(&String::new())) + .bearer_auth(&access_token) .json(&json!({ "name": app_name, - "redirectUris": [redirect_uri, "http://localhost:3000/auth/callback", "http://localhost:9000/auth/callback"], + "redirectUris": [redirect_uri, "http://localhost:3000/auth/callback", "http://localhost:8080/auth/callback"], "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], "grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN", "OIDC_GRANT_TYPE_PASSWORD"], "appType": "OIDC_APP_TYPE_WEB", "authMethodType": "OIDC_AUTH_METHOD_TYPE_POST", - "postLogoutRedirectUris": ["http://localhost:9000", "http://localhost:3000", "http://localhost:9000"], + "postLogoutRedirectUris": ["http://localhost:8080", "http://localhost:3000", "http://localhost:8080"], "accessTokenType": "OIDC_TOKEN_TYPE_BEARER", "devMode": true, })) .send() .await?; + if !app_response.status().is_success() { + let error_text = app_response.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!("Failed to create OAuth application: {}", error_text)); + } + let app_result: serde_json::Value = app_response.json().await?; let client_id = app_result["clientId"].as_str().unwrap_or("").to_string(); let client_secret = app_result["clientSecret"] @@ -376,6 +498,11 @@ impl DirectorySetup { .unwrap_or("") .to_string(); + if client_id.is_empty() { + return Err(anyhow::anyhow!("Client ID is empty in response")); + } + + log::info!("Created OAuth application with client_id: {}", client_id); Ok((project_id, client_id, client_secret)) } @@ -432,8 +559,21 @@ impl DirectorySetup { } async fn save_config_internal(&self, config: &DirectoryConfig) -> Result<()> { + // Ensure parent directory exists + if let Some(parent) = self.config_path.parent() { + if !parent.exists() { + fs::create_dir_all(parent).await.map_err(|e| { + anyhow::anyhow!("Failed to create config directory {}: {}", parent.display(), e) + })?; + log::info!("Created config directory: {}", parent.display()); + } + } + let json = serde_json::to_string_pretty(config)?; - fs::write(&self.config_path, json).await?; + fs::write(&self.config_path, json).await.map_err(|e| { + anyhow::anyhow!("Failed to write config to {}: {}", self.config_path.display(), e) + })?; + log::info!("Saved Directory configuration to {}", self.config_path.display()); Ok(()) } @@ -466,9 +606,10 @@ Database: Machine: Identification: Hostname: localhost - WebhookAddress: http://localhost:9000 + WebhookAddress: http://localhost:8080 -ExternalDomain: localhost:9000 +Port: 9000 +ExternalDomain: localhost ExternalPort: 9000 ExternalSecure: false diff --git a/src/directory/auth_routes.rs b/src/directory/auth_routes.rs index 499ab0f1d..db455d21c 100644 --- a/src/directory/auth_routes.rs +++ b/src/directory/auth_routes.rs @@ -379,8 +379,7 @@ pub async fn get_current_user( let session_token = headers .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) - .and_then(|auth| auth.strip_prefix("Bearer ")) - .filter(|token| !token.is_empty()); + .and_then(|auth| auth.strip_prefix("Bearer ")); match session_token { None => { @@ -398,6 +397,21 @@ pub async fn get_current_user( is_anonymous: true, }) } + Some(token) if token.is_empty() => { + info!("get_current_user: empty authorization token - returning anonymous user"); + Json(CurrentUserResponse { + id: None, + username: None, + email: None, + first_name: None, + last_name: None, + display_name: None, + roles: None, + organization_id: None, + avatar_url: None, + is_anonymous: true, + }) + } Some(session_token) => { info!("get_current_user: looking up session token (len={}, prefix={}...)", session_token.len(),