From 225859b61487348630cb81d7cf79fd8801554982 Mon Sep 17 00:00:00 2001 From: Travis Vasceannie Date: Sat, 24 Jan 2026 02:41:24 +0000 Subject: [PATCH] Commit all current changes --- dashboard/src/app/workspaces/page.tsx | 23 +++ src/api/ai_providers.rs | 57 +++--- src/api/backends.rs | 36 ++-- src/api/control.rs | 2 +- src/api/fs.rs | 16 +- src/api/mission_runner.rs | 14 +- src/api/routes.rs | 4 +- src/api/system.rs | 261 ++++++++++++++++++++++---- src/backend/claudecode/client.rs | 13 +- src/backend/claudecode/mod.rs | 4 +- src/backend_config.rs | 6 +- src/tools/terminal.rs | 41 ++-- src/workspace.rs | 25 +-- 13 files changed, 374 insertions(+), 128 deletions(-) diff --git a/dashboard/src/app/workspaces/page.tsx b/dashboard/src/app/workspaces/page.tsx index b7ad311..af70470 100644 --- a/dashboard/src/app/workspaces/page.tsx +++ b/dashboard/src/app/workspaces/page.tsx @@ -70,6 +70,7 @@ export default function WorkspacesPage() { const [newWorkspaceName, setNewWorkspaceName] = useState(''); const [newWorkspaceType, setNewWorkspaceType] = useState<'host' | 'chroot'>('chroot'); const [newWorkspaceTemplate, setNewWorkspaceTemplate] = useState(''); + const [newWorkspacePath, setNewWorkspacePath] = useState(''); const [skillsFilter, setSkillsFilter] = useState(''); const [selectedSkills, setSelectedSkills] = useState([]); const [workspaceTab, setWorkspaceTab] = useState<'overview' | 'skills' | 'environment' | 'template' | 'build'>('overview'); @@ -264,10 +265,15 @@ export default function WorkspacesPage() { try { setCreating(true); const workspaceType = newWorkspaceTemplate ? 'chroot' : newWorkspaceType; + const hostPath = + workspaceType === 'host' + ? (newWorkspacePath.trim() || `workspaces/${newWorkspaceName}`) + : undefined; const created = await createWorkspace({ name: newWorkspaceName, workspace_type: workspaceType, template: newWorkspaceTemplate || undefined, + path: hostPath, }); // Refresh workspace list immediately after creation so it appears in the UI @@ -294,6 +300,7 @@ export default function WorkspacesPage() { } setShowNewWorkspaceDialog(false); setNewWorkspaceName(''); + setNewWorkspacePath(''); setNewWorkspaceTemplate(''); setSelectedWorkspace(workspaceToShow); } catch (err) { @@ -1112,6 +1119,22 @@ export default function WorkspacesPage() { : 'Creates isolated Linux filesystem'}

+ + {!newWorkspaceTemplate && newWorkspaceType === 'host' && ( +
+ + setNewWorkspacePath(e.target.value)} + className="w-full px-3 py-2.5 rounded-lg bg-black/20 border border-white/[0.06] text-sm text-white placeholder:text-white/25 focus:outline-none focus:border-indigo-500/50" + /> +

+ Leave blank to use workspaces/{newWorkspaceName || 'name'} +

+
+ )}
diff --git a/src/api/ai_providers.rs b/src/api/ai_providers.rs index c016f50..ad54c69 100644 --- a/src/api/ai_providers.rs +++ b/src/api/ai_providers.rs @@ -184,13 +184,11 @@ fn get_anthropic_key_from_opencode_auth() -> Option { // Check for API key first let auth_type = anthropic_auth.get("type").and_then(|v| v.as_str()); match auth_type { - Some("api_key") | Some("api") => { - anthropic_auth - .get("key") - .or_else(|| anthropic_auth.get("api_key")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - } + Some("api_key") | Some("api") => anthropic_auth + .get("key") + .or_else(|| anthropic_auth.get("api_key")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), Some("oauth") => { // Return OAuth access token - Claude CLI can use this anthropic_auth @@ -1348,7 +1346,9 @@ async fn get_provider_for_backend( Ok(Json(BackendProviderResponse { configured: true, provider_type: Some("anthropic".to_string()), - provider_name: anthropic_config.and_then(|c| c.name).or_else(|| Some("Anthropic".to_string())), + provider_name: anthropic_config + .and_then(|c| c.name) + .or_else(|| Some("Anthropic".to_string())), api_key, oauth, has_credentials, @@ -1894,25 +1894,28 @@ async fn oauth_callback_inner( // 2. The old format: code#state // 3. Just the code let input = req.code.trim(); - let (code_string, state_string): (String, Option) = if let Ok(url) = url::Url::parse(input) { - // Parse as URL - let code = url.query_pairs() - .find(|(k, _)| k == "code") - .map(|(_, v)| v.to_string()); - let state = url.query_pairs() - .find(|(k, _)| k == "state") - .map(|(_, v)| v.to_string()); - (code.unwrap_or_default(), state) - } else if input.contains('#') { - // Old format: code#state - let mut parts = input.splitn(2, '#'); - let code = parts.next().unwrap_or(input).to_string(); - let state = parts.next().map(|s| s.to_string()); - (code, state) - } else { - // Just the code - (input.to_string(), None) - }; + let (code_string, state_string): (String, Option) = + if let Ok(url) = url::Url::parse(input) { + // Parse as URL + let code = url + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()); + let state = url + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.to_string()); + (code.unwrap_or_default(), state) + } else if input.contains('#') { + // Old format: code#state + let mut parts = input.splitn(2, '#'); + let code = parts.next().unwrap_or(input).to_string(); + let state = parts.next().map(|s| s.to_string()); + (code, state) + } else { + // Just the code + (input.to_string(), None) + }; if code_string.is_empty() { return Err(( diff --git a/src/api/backends.rs b/src/api/backends.rs index 60bfbf0..b5b8487 100644 --- a/src/api/backends.rs +++ b/src/api/backends.rs @@ -113,11 +113,12 @@ pub async fn get_backend_config( .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Backend {} not found", id)))?; drop(registry); - let config_entry = state - .backend_configs - .get(&id) - .await - .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Backend {} not configured", id)))?; + let config_entry = state.backend_configs.get(&id).await.ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + format!("Backend {} not configured", id), + ) + })?; let mut settings = config_entry.settings.clone(); @@ -169,10 +170,12 @@ pub async fn update_backend_config( let updated_settings = match id.as_str() { "opencode" => { - let settings = req - .settings - .as_object() - .ok_or_else(|| (StatusCode::BAD_REQUEST, "Invalid settings payload".to_string()))?; + let settings = req.settings.as_object().ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + "Invalid settings payload".to_string(), + ) + })?; let base_url = settings .get("base_url") .and_then(|v| v.as_str()) @@ -197,15 +200,12 @@ pub async fn update_backend_config( "claudecode" => { let mut settings = req.settings.clone(); if let Some(api_key) = settings.get("api_key").and_then(|v| v.as_str()) { - let store = state - .secrets - .as_ref() - .ok_or_else(|| { - ( - StatusCode::BAD_REQUEST, - "Secrets store not available".to_string(), - ) - })?; + let store = state.secrets.as_ref().ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + "Secrets store not available".to_string(), + ) + })?; store .set_secret("claudecode", "api_key", api_key, None) .await diff --git a/src/api/control.rs b/src/api/control.rs index 8cec7ca..49c6270 100644 --- a/src/api/control.rs +++ b/src/api/control.rs @@ -3641,7 +3641,7 @@ async fn run_single_control_turn( tracing::warn!("Failed to prepare mission workspace: {}", e); ws.path.clone() } - }; + }; (dir, Some(ws)) } else { ( diff --git a/src/api/fs.rs b/src/api/fs.rs index 36ac19e..61f54c6 100644 --- a/src/api/fs.rs +++ b/src/api/fs.rs @@ -169,16 +169,12 @@ async fn resolve_path_for_workspace( path: &str, mission_id: Option, ) -> Result { - let workspace = state - .workspaces - .get(workspace_id) - .await - .ok_or_else(|| { - ( - StatusCode::NOT_FOUND, - format!("Workspace {} not found", workspace_id), - ) - })?; + let workspace = state.workspaces.get(workspace_id).await.ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + format!("Workspace {} not found", workspace_id), + ) + })?; let workspace_root = workspace.path.canonicalize().map_err(|e| { ( diff --git a/src/api/mission_runner.rs b/src/api/mission_runner.rs index d20b38b..7366230 100644 --- a/src/api/mission_runner.rs +++ b/src/api/mission_runner.rs @@ -19,7 +19,9 @@ use tokio_util::sync::CancellationToken; use uuid::Uuid; use crate::agents::{AgentContext, AgentRef, AgentResult, TerminalReason}; -use crate::backend::claudecode::client::{ClaudeCodeClient, ClaudeCodeConfig, ClaudeEvent, ContentBlock, StreamEvent}; +use crate::backend::claudecode::client::{ + ClaudeCodeClient, ClaudeCodeConfig, ClaudeEvent, ContentBlock, StreamEvent, +}; use crate::config::Config; use crate::mcp::McpRegistry; use crate::secrets::SecretsStore; @@ -574,7 +576,10 @@ fn get_claudecode_cli_path_from_config(_app_working_dir: &std::path::Path) -> Op if let Some(settings) = config.get("settings") { if let Some(cli_path) = settings.get("cli_path").and_then(|v| v.as_str()) { if !cli_path.is_empty() { - tracing::info!("Using Claude Code CLI path from backend config: {}", cli_path); + tracing::info!( + "Using Claude Code CLI path from backend config: {}", + cli_path + ); return Some(cli_path.to_string()); } } @@ -596,8 +601,8 @@ pub async fn run_claudecode_turn( secrets: Option>, app_working_dir: &std::path::Path, ) -> AgentResult { - use std::collections::HashMap; use super::ai_providers::get_anthropic_api_key_for_claudecode; + use std::collections::HashMap; // Try to get API key from Anthropic provider configured for Claude Code backend let api_key = if let Some(key) = get_anthropic_api_key_for_claudecode(app_working_dir) { @@ -665,8 +670,7 @@ pub async fn run_claudecode_turn( mission_id: Some(mission_id), resumable: true, }); - return AgentResult::failure(err_msg, 0) - .with_terminal_reason(TerminalReason::LlmError); + return AgentResult::failure(err_msg, 0).with_terminal_reason(TerminalReason::LlmError); } }; diff --git a/src/api/routes.rs b/src/api/routes.rs index 47c6885..3825153 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -156,9 +156,7 @@ pub async fn serve(config: Config) -> anyhow::Result<()> { ]; let backend_configs = Arc::new( crate::backend_config::BackendConfigStore::new( - config - .working_dir - .join(".openagent/backend_config.json"), + config.working_dir.join(".openagent/backend_config.json"), backend_defaults, ) .await, diff --git a/src/api/system.rs b/src/api/system.rs index e5e0bca..07fee2a 100644 --- a/src/api/system.rs +++ b/src/api/system.rs @@ -3,6 +3,7 @@ //! Provides endpoints to query and update system components like OpenCode //! and oh-my-opencode. +use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; @@ -25,6 +26,54 @@ use super::routes::AppState; /// Default repo path for Open Agent source const OPEN_AGENT_REPO_PATH: &str = "/opt/open_agent/vaduz-v1"; +fn resolve_opencode_binary_path() -> PathBuf { + if let Ok(path) = std::env::var("OPENCODE_BIN_PATH") { + if !path.trim().is_empty() { + return PathBuf::from(path); + } + } + + if let Ok(path) = std::env::var("OPENCODE_BIN") { + if !path.trim().is_empty() { + return PathBuf::from(path); + } + } + + if let Ok(home) = std::env::var("HOME") { + let candidate = PathBuf::from(home).join(".opencode/bin/opencode"); + if candidate.exists() { + return candidate; + } + } + + PathBuf::from("/usr/local/bin/opencode") +} + +fn resolve_claude_code_binary_path() -> Option { + for key in ["CLAUDE_CLI_PATH", "CLAUDE_CODE_BIN_PATH", "CLAUDE_BIN_PATH"] { + if let Ok(path) = std::env::var(key) { + let trimmed = path.trim(); + if !trimmed.is_empty() { + let candidate = PathBuf::from(trimmed); + if candidate.exists() { + return Some(candidate); + } + } + } + } + None +} + +fn parse_claude_code_version(line: &str) -> Option { + for token in line.split_whitespace() { + let cleaned = token.trim_matches(|c: char| !c.is_ascii_digit() && c != '.'); + if cleaned.chars().next().is_some_and(|c| c.is_ascii_digit()) { + return Some(cleaned.to_string()); + } + } + None +} + /// Information about a system component. #[derive(Debug, Clone, Serialize)] pub struct ComponentInfo { @@ -89,7 +138,10 @@ pub fn routes() -> Router> { .route("/components", get(get_components)) .route("/components/:name/update", post(update_component)) .route("/plugins/installed", get(get_installed_plugins)) - .route("/plugins/:package/update", post(update_plugin)) + .route( + "/plugins/:package/update", + get(update_plugin).post(update_plugin), + ) } /// Get information about all system components. @@ -130,6 +182,9 @@ async fn get_components(State(state): State>) -> Json ComponentInfo { + let opencode_path = resolve_opencode_binary_path(); + let opencode_path_display = opencode_path.display().to_string(); + // Try to get version from the health endpoint let client = reqwest::Client::new(); let health_url = format!("{}/global/health", config.opencode_base_url); @@ -155,7 +210,7 @@ async fn get_opencode_info(config: &crate::config::Config) -> ComponentInfo { version, installed: true, update_available, - path: Some("/usr/local/bin/opencode".to_string()), + path: Some(opencode_path_display.clone()), status, }; } @@ -164,7 +219,7 @@ async fn get_opencode_info(config: &crate::config::Config) -> ComponentInfo { } // Fallback: try to run opencode --version - match Command::new("opencode").arg("--version").output().await { + match Command::new(&opencode_path).arg("--version").output().await { Ok(output) if output.status.success() => { let version_str = String::from_utf8_lossy(&output.stdout); let version = version_str.lines().next().map(|l| { @@ -185,7 +240,7 @@ async fn get_opencode_info(config: &crate::config::Config) -> ComponentInfo { version, installed: true, update_available, - path: Some("/usr/local/bin/opencode".to_string()), + path: Some(opencode_path_display.clone()), status, } } @@ -202,15 +257,28 @@ async fn get_opencode_info(config: &crate::config::Config) -> ComponentInfo { /// Get Claude Code version and status. async fn get_claude_code_info() -> ComponentInfo { + let cli_path = which_claude_code().await; + // Try to run claude --version to check if it's installed - match Command::new("claude").arg("--version").output().await { + let Some(cli_path) = cli_path else { + return ComponentInfo { + name: "claude_code".to_string(), + version: None, + installed: false, + update_available: None, + path: None, + status: ComponentStatus::NotInstalled, + }; + }; + + match Command::new(&cli_path).arg("--version").output().await { Ok(output) if output.status.success() => { let version_str = String::from_utf8_lossy(&output.stdout); - // Parse version from output like "claude 1.0.3" let version = version_str .lines() .next() - .map(|l| l.trim().replace("claude ", "").replace("Claude ", "")); + .and_then(parse_claude_code_version) + .or_else(|| version_str.lines().next().map(|l| l.trim().to_string())); let update_available = check_claude_code_update(version.as_deref()).await; let status = if update_available.is_some() { @@ -224,7 +292,7 @@ async fn get_claude_code_info() -> ComponentInfo { version, installed: true, update_available, - path: which_claude_code().await, + path: Some(cli_path), status, } } @@ -241,13 +309,13 @@ async fn get_claude_code_info() -> ComponentInfo { /// Find the path to the Claude Code binary. async fn which_claude_code() -> Option { + if let Some(path) = resolve_claude_code_binary_path() { + return Some(path.display().to_string()); + } + let output = Command::new("which").arg("claude").output().await.ok()?; if output.status.success() { - Some( - String::from_utf8_lossy(&output.stdout) - .trim() - .to_string(), - ) + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) } else { None } @@ -809,18 +877,59 @@ fn stream_opencode_update() -> impl Stream { + None => { yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { event_type: "log".to_string(), - message: "Binary installed, restarting service...".to_string(), + message: format!("Binary already installed at {}, restarting service...", opencode_path_display), progress: Some(80), }).unwrap())); @@ -844,21 +953,102 @@ fn stream_opencode_update() -> impl Stream { let stderr = String::from_utf8_lossy(&output.stderr); yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { - event_type: "error".to_string(), - message: format!("Failed to restart service: {}", stderr), - progress: None, + event_type: "log".to_string(), + message: format!( + "OpenCode installed at {}, but failed to restart opencode.service: {}. Please run: sudo systemctl restart opencode.service", + opencode_path_display, + stderr.trim() + ), + progress: Some(95), + }).unwrap())); + + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "complete".to_string(), + message: "OpenCode updated; manual restart required.".to_string(), + progress: Some(100), }).unwrap())); } Err(e) => { yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { - event_type: "error".to_string(), - message: format!("Failed to restart service: {}", e), - progress: None, + event_type: "log".to_string(), + message: format!( + "OpenCode installed at {}, but failed to restart opencode.service: {}. Please run: sudo systemctl restart opencode.service", + opencode_path_display, + e + ), + progress: Some(95), + }).unwrap())); + + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "complete".to_string(), + message: "OpenCode updated; manual restart required.".to_string(), + progress: Some(100), }).unwrap())); } } } - Ok(output) => { + Some(Ok(output)) if output.status.success() => { + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "log".to_string(), + message: format!("Binary installed at {}, restarting service...", opencode_path_display), + progress: Some(80), + }).unwrap())); + + // Restart the opencode service + let restart_result = Command::new("systemctl") + .args(["restart", "opencode.service"]) + .output() + .await; + + match restart_result { + Ok(output) if output.status.success() => { + // Wait a moment for the service to start + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "complete".to_string(), + message: "OpenCode updated successfully!".to_string(), + progress: Some(100), + }).unwrap())); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "log".to_string(), + message: format!( + "OpenCode installed at {}, but failed to restart opencode.service: {}. Please run: sudo systemctl restart opencode.service", + opencode_path_display, + stderr.trim() + ), + progress: Some(95), + }).unwrap())); + + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "complete".to_string(), + message: "OpenCode updated; manual restart required.".to_string(), + progress: Some(100), + }).unwrap())); + } + Err(e) => { + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "log".to_string(), + message: format!( + "OpenCode installed at {}, but failed to restart opencode.service: {}. Please run: sudo systemctl restart opencode.service", + opencode_path_display, + e + ), + progress: Some(95), + }).unwrap())); + + yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { + event_type: "complete".to_string(), + message: "OpenCode updated; manual restart required.".to_string(), + progress: Some(100), + }).unwrap())); + } + } + } + Some(Ok(output)) => { let stderr = String::from_utf8_lossy(&output.stderr); yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { event_type: "error".to_string(), @@ -866,7 +1056,7 @@ fn stream_opencode_update() -> impl Stream { + Some(Err(e)) => { yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { event_type: "error".to_string(), message: format!("Failed to install binary: {}", e), @@ -934,19 +1124,26 @@ fn stream_claude_code_update() -> impl Stream { - let version = String::from_utf8_lossy(&output.stdout) + let version_line = String::from_utf8_lossy(&output.stdout) .lines() .next() - .map(|l| l.trim().to_string()) - .unwrap_or_else(|| "unknown".to_string()); + .unwrap_or("") + .trim() + .to_string(); + let version = parse_claude_code_version(&version_line) + .unwrap_or_else(|| version_line); yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { event_type: "complete".to_string(), diff --git a/src/backend/claudecode/client.rs b/src/backend/claudecode/client.rs index 30913ab..17bbb87 100644 --- a/src/backend/claudecode/client.rs +++ b/src/backend/claudecode/client.rs @@ -148,9 +148,7 @@ pub enum ContentBlock { is_error: bool, }, #[serde(rename = "thinking")] - Thinking { - thinking: String, - }, + Thinking { thinking: String }, } #[derive(Debug, Clone, Deserialize)] @@ -208,8 +206,7 @@ pub struct ClaudeCodeConfig { impl Default for ClaudeCodeConfig { fn default() -> Self { Self { - cli_path: std::env::var("CLAUDE_CLI_PATH") - .unwrap_or_else(|_| "claude".to_string()), + cli_path: std::env::var("CLAUDE_CLI_PATH").unwrap_or_else(|_| "claude".to_string()), api_key: std::env::var("ANTHROPIC_API_KEY").ok(), default_model: None, } @@ -299,7 +296,11 @@ impl ClaudeCodeClient { let mut child = cmd.spawn().map_err(|e| { error!("Failed to spawn Claude CLI: {}", e); - anyhow!("Failed to spawn Claude CLI: {}. Is it installed at '{}'?", e, self.config.cli_path) + anyhow!( + "Failed to spawn Claude CLI: {}. Is it installed at '{}'?", + e, + self.config.cli_path + ) })?; // Write message to stdin diff --git a/src/backend/claudecode/mod.rs b/src/backend/claudecode/mod.rs index 2206d7d..29bd861 100644 --- a/src/backend/claudecode/mod.rs +++ b/src/backend/claudecode/mod.rs @@ -265,9 +265,7 @@ fn convert_claude_event( ClaudeEvent::Result(res) => { if res.is_error || res.subtype == "error" { results.push(ExecutionEvent::Error { - message: res - .result - .unwrap_or_else(|| "Unknown error".to_string()), + message: res.result.unwrap_or_else(|| "Unknown error".to_string()), }); } else { debug!( diff --git a/src/backend_config.rs b/src/backend_config.rs index 1828f5b..4991cd1 100644 --- a/src/backend_config.rs +++ b/src/backend_config.rs @@ -21,7 +21,11 @@ fn default_enabled() -> bool { } impl BackendConfigEntry { - pub fn new(id: impl Into, name: impl Into, settings: serde_json::Value) -> Self { + pub fn new( + id: impl Into, + name: impl Into, + settings: serde_json::Value, + ) -> Self { Self { id: id.into(), name: name.into(), diff --git a/src/tools/terminal.rs b/src/tools/terminal.rs index ed48b6e..32f624f 100644 --- a/src/tools/terminal.rs +++ b/src/tools/terminal.rs @@ -259,19 +259,38 @@ fn default_timeout_from_env() -> Duration { Duration::from_secs_f64(DEFAULT_COMMAND_TIMEOUT_SECS) } -fn parse_timeout(args: &Value) -> Duration { - if let Some(ms) = args.get("timeout_ms").and_then(|v| v.as_u64()) { - return Duration::from_millis(ms.max(1)); - } - if let Some(secs) = args.get("timeout_secs").and_then(|v| v.as_u64()) { - return Duration::from_secs(secs.max(1)); - } - if let Some(secs) = args.get("timeout").and_then(|v| v.as_f64()) { - if secs > 0.0 { - return Duration::from_secs_f64(secs); +fn max_timeout_from_env() -> Option { + if let Ok(raw) = env::var("OPEN_AGENT_COMMAND_TIMEOUT_MAX_SECS") { + if let Ok(value) = raw.parse::() { + if value > 0.0 { + return Some(Duration::from_secs_f64(value)); + } } } - default_timeout_from_env() + None +} + +fn parse_timeout(args: &Value) -> Duration { + let mut timeout = if let Some(ms) = args.get("timeout_ms").and_then(|v| v.as_u64()) { + Duration::from_millis(ms.max(1)) + } else if let Some(secs) = args.get("timeout_secs").and_then(|v| v.as_u64()) { + Duration::from_secs(secs.max(1)) + } else if let Some(secs) = args.get("timeout").and_then(|v| v.as_f64()) { + if secs > 0.0 { + Duration::from_secs_f64(secs) + } else { + default_timeout_from_env() + } + } else { + default_timeout_from_env() + }; + + if let Some(max) = max_timeout_from_env() { + if timeout > max { + timeout = max; + } + } + timeout } fn parse_env(args: &Value) -> HashMap { diff --git a/src/workspace.rs b/src/workspace.rs index 16d5b9a..a34e860 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -579,6 +579,11 @@ fn opencode_entry_from_mcp( merged_env .entry("WORKING_DIR".to_string()) .or_insert_with(|| workspace_dir.to_string_lossy().to_string()); + if config.name == "workspace" { + merged_env + .entry("OPEN_AGENT_COMMAND_TIMEOUT_MAX_SECS".to_string()) + .or_insert_with(|| "55".to_string()); + } if workspace_type == WorkspaceType::Chroot { if let Some(name) = workspace_root.file_name().and_then(|n| n.to_str()) { if !name.trim().is_empty() { @@ -739,7 +744,10 @@ fn claude_entry_from_mcp( entry.insert("command".to_string(), json!(command)); entry.insert("args".to_string(), json!(args)); - if let Some(env) = opencode_entry.get("environment").and_then(|v| v.as_object()) { + if let Some(env) = opencode_entry + .get("environment") + .and_then(|v| v.as_object()) + { entry.insert("env".to_string(), serde_json::Value::Object(env.clone())); } @@ -1544,14 +1552,8 @@ pub async fn prepare_mission_workspace_with_skills( library: Option<&LibraryStore>, mission_id: Uuid, ) -> anyhow::Result { - prepare_mission_workspace_with_skills_backend( - workspace, - mcp, - library, - mission_id, - "opencode", - ) - .await + prepare_mission_workspace_with_skills_backend(workspace, mcp, library, mission_id, "opencode") + .await } /// Prepare a workspace directory for a mission with skill and tool syncing for a specific backend. @@ -1575,8 +1577,9 @@ pub async fn prepare_mission_workspace_with_skills_backend( if backend_id == "claudecode" { if let Some(lib) = library { let context = format!("mission-{}", mission_id); - let skill_names = - resolve_workspace_skill_names(workspace, lib).await.unwrap_or_default(); + let skill_names = resolve_workspace_skill_names(workspace, lib) + .await + .unwrap_or_default(); let skills = collect_skill_contents(&skill_names, &context, lib).await; skill_contents = Some(skills); }