From eddd40594d089bf222288f860398afa0eb04b099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:32:25 +0100 Subject: [PATCH 1/6] feat(docker): enhance local development setup with Docker Compose support for dashboard --- .github/workflows/shell/check-commit-msg.sh | 24 +- Makefile | 18 +- clients/agent-runtime/README.md | 22 + clients/agent-runtime/docker-compose.yml | 22 +- .../npm/corvus-cli/scripts/postinstall.js | 14 +- .../scripts/test_dockerignore.sh | 10 +- clients/agent-runtime/src/agent/classifier.rs | 59 ++- clients/agent-runtime/src/channels/mod.rs | 136 +++--- clients/agent-runtime/src/gateway/admin.rs | 204 ++++++--- clients/agent-runtime/src/tools/browser.rs | 396 ++++++++++-------- .../agent-runtime/src/tools/git_operations.rs | 57 ++- clients/agent-runtime/src/tools/mod.rs | 262 +++++++----- clients/web/apps/dashboard/Dockerfile | 28 ++ clients/web/apps/dashboard/README.md | 10 + .../web/packages/locales/src/parity.spec.ts | 46 +- clients/web/packages/shared/index.ts | 14 - dev/README.md | 14 + dev/cli.sh | 31 +- dev/docker-compose.yml | 16 + sync-version-with-tag.sh | 2 + 20 files changed, 886 insertions(+), 499 deletions(-) create mode 100644 clients/web/apps/dashboard/Dockerfile diff --git a/.github/workflows/shell/check-commit-msg.sh b/.github/workflows/shell/check-commit-msg.sh index 236624608..5ecc4e542 100755 --- a/.github/workflows/shell/check-commit-msg.sh +++ b/.github/workflows/shell/check-commit-msg.sh @@ -26,26 +26,26 @@ COMMIT_MSG_PATTERN='^(revert: )?(build|chore|ci|deps|docs|feat|fix|infra|perf|re # ------------------------------ # Skip merge or initial commit # ------------------------------ -if echo "$COMMIT_MSG" | grep -Eq '^Merge'; then +if grep -Eq '^Merge' <<< "$COMMIT_MSG"; then echo "⏭ Skipping merge commit." exit 0 fi -if echo "$COMMIT_MSG" | grep -Eq '^Initial commit'; then +if grep -Eq '^Initial commit' <<< "$COMMIT_MSG"; then echo "⏭ Skipping initial commit." exit 0 fi -if ! echo "$COMMIT_MSG" | grep -Eq "$COMMIT_MSG_PATTERN"; then - echo -e "${BG_RED}ERROR${RESET} ${RED}invalid commit message format.${RESET}\n" - echo -e "${RED}Proper commit message format is required for automated changelog generation. Examples:${RESET}\n" - echo -e " ${GREEN}feat(parser): add support for empty tuples${RESET}" - echo -e " ${GREEN}fix(runtime): handle reconnect race condition${RESET}" - echo -e " ${GREEN}refactor(core)!: remove legacy provider fallback${RESET}\n" - echo -e "${RED}Commit message header: (): ${RESET}" - echo -e "${RED}Commit message header pattern: ${COMMIT_MSG_PATTERN}${RESET}" - echo -e "${RED}See${RESET} ${BLUE}https://www.conventionalcommits.org/en/v1.0.0/${RESET} ${RED}for more details.${RESET}\n" - echo -e "${RED}❌ Invalid commit message:${RESET} '${COMMIT_MSG}'" +if ! grep -Eq "$COMMIT_MSG_PATTERN" <<< "$COMMIT_MSG"; then + echo -e "${BG_RED}ERROR${RESET} ${RED}invalid commit message format.${RESET}\n" >&2 + echo -e "${RED}Proper commit message format is required for automated changelog generation. Examples:${RESET}\n" >&2 + echo -e " ${GREEN}feat(parser): add support for empty tuples${RESET}" >&2 + echo -e " ${GREEN}fix(runtime): handle reconnect race condition${RESET}" >&2 + echo -e " ${GREEN}refactor(core)!: remove legacy provider fallback${RESET}\n" >&2 + echo -e "${RED}Commit message header: (): ${RESET}" >&2 + echo -e "${RED}Commit message header pattern: ${COMMIT_MSG_PATTERN}${RESET}" >&2 + echo -e "${RED}See${RESET} ${BLUE}https://www.conventionalcommits.org/en/v1.0.0/${RESET} ${RED}for more details.${RESET}\n" >&2 + echo -e "${RED}❌ Invalid commit message:${RESET} '${COMMIT_MSG}'" >&2 exit 1 fi diff --git a/Makefile b/Makefile index ecc8e8908..9e8ba0829 100644 --- a/Makefile +++ b/Makefile @@ -322,6 +322,19 @@ dev-build: ## Rebuild dev images dev-clean: ## Stop and wipe dev environment @./dev/cli.sh clean +# --- LOCAL RUNTIME (Docker Compose) --- + +runtime-up: ## Start local gateway runtime (clients/agent-runtime) + @docker compose -f clients/agent-runtime/docker-compose.yml up -d +runtime-up-dashboard: ## Start local gateway + dashboard runtime + @docker compose -f clients/agent-runtime/docker-compose.yml --profile dashboard up -d +runtime-down: ## Stop local gateway/dashboard runtime + @docker compose -f clients/agent-runtime/docker-compose.yml down +runtime-logs: ## Follow local gateway/dashboard logs + @docker compose -f clients/agent-runtime/docker-compose.yml logs -f +runtime-status: ## Show local gateway/dashboard status + @docker compose -f clients/agent-runtime/docker-compose.yml ps + # --- CONTINUOUS INTEGRATION --- ci-build: ## CI: Build without daemon @@ -365,5 +378,6 @@ sync-version: ## Sync VERSION with git tag format check-format check lint-kotlin lint-rust lint-android lint-all \ test test-app test-core test-verbose test-coverage docs-code \ deps deps-app deps-analysis deps-update \ - dev-up dev-down dev-shell dev-agent dev-logs dev-status dev-build dev-clean \ - ci-build ci-test ci-check all quick tasks info version sync-version + dev-up dev-down dev-shell dev-agent dev-logs dev-status dev-build dev-clean \ + runtime-up runtime-up-dashboard runtime-down runtime-logs runtime-status \ + ci-build ci-test ci-check all quick tasks info version sync-version diff --git a/clients/agent-runtime/README.md b/clients/agent-runtime/README.md index e70df8a2f..3a5c71a92 100755 --- a/clients/agent-runtime/README.md +++ b/clients/agent-runtime/README.md @@ -65,6 +65,28 @@ yarn global add @dallay/corvus bun add -g @dallay/corvus ``` +### Docker Compose (local-first dashboard) + +```bash +# From clients/agent-runtime +cp docker-compose.yml docker-compose.local.yml + +# Edit API_KEY (and optional provider/model) in your local compose/env + +# Gateway only +docker compose -f docker-compose.local.yml up -d + +# Gateway + dashboard +docker compose -f docker-compose.local.yml --profile dashboard up -d +``` + +Open: + +- Gateway: `http://localhost:3000` +- Dashboard: `http://localhost:4324` + +Then pair in dashboard via `/pair` and use the returned bearer token for admin config actions. + Build from source (Rust toolchain): ```bash diff --git a/clients/agent-runtime/docker-compose.yml b/clients/agent-runtime/docker-compose.yml index 3fbc99c95..caa643c56 100755 --- a/clients/agent-runtime/docker-compose.yml +++ b/clients/agent-runtime/docker-compose.yml @@ -2,8 +2,9 @@ # # Quick start: # 1. Copy this file and set your API key -# 2. Run: docker-compose up -d -# 3. Access gateway at http://localhost:3000 +# 2. Run gateway only: docker compose up -d +# 3. Run gateway + dashboard: docker compose --profile dashboard up -d +# 4. Access gateway at http://localhost:3000 and dashboard at http://localhost:4324 # # For more info: https://github.com/dallay/corvus @@ -43,7 +44,7 @@ services: ports: # Gateway API port - - "3000:3000" + - "127.0.0.1:3000:3000" # Health check healthcheck: @@ -61,5 +62,20 @@ services: ports: - "8000:8000" + # Optional local web dashboard (local-first) + dashboard: + build: + context: ../web + dockerfile: apps/dashboard/Dockerfile + image: corvus-dashboard:local + container_name: corvus-dashboard + restart: unless-stopped + depends_on: + corvus: + condition: service_healthy + profiles: ["dashboard"] + ports: + - "127.0.0.1:4324:8080" + volumes: corvus-data: diff --git a/clients/agent-runtime/npm/corvus-cli/scripts/postinstall.js b/clients/agent-runtime/npm/corvus-cli/scripts/postinstall.js index 66d9fc8ac..e7fb8eac4 100755 --- a/clients/agent-runtime/npm/corvus-cli/scripts/postinstall.js +++ b/clients/agent-runtime/npm/corvus-cli/scripts/postinstall.js @@ -2,12 +2,12 @@ const { ensureBinary } = require('../lib/install'); -(async () => { - try { - const binaryPath = await ensureBinary(); +ensureBinary() + .then((binaryPath) => { console.log(`[corvus] Native binary ready at ${binaryPath}`); - } catch (error) { - console.warn(`[corvus] Postinstall skipped: ${error.message}`); + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[corvus] Postinstall skipped: ${message}`); console.warn('[corvus] Binary will be downloaded on first run.'); - } -})(); + }); diff --git a/clients/agent-runtime/scripts/test_dockerignore.sh b/clients/agent-runtime/scripts/test_dockerignore.sh index 839d21e58..47eb6ad1d 100755 --- a/clients/agent-runtime/scripts/test_dockerignore.sh +++ b/clients/agent-runtime/scripts/test_dockerignore.sh @@ -16,13 +16,17 @@ PASS=0 FAIL=0 log_pass() { - echo -e "${GREEN}✓${NC} $1" + local message="$1" + echo -e "${GREEN}✓${NC} $message" PASS=$((PASS + 1)) + return 0 } log_fail() { - echo -e "${RED}✗${NC} $1" + local message="$1" + echo -e "${RED}✗${NC} $message" FAIL=$((FAIL + 1)) + return 0 } # Test 1: .dockerignore exists @@ -96,7 +100,7 @@ echo "=== Simulating Docker build context ===" # Create temp dir and simulate what would be sent TEMP_DIR=$(mktemp -d) -trap "rm -rf $TEMP_DIR" EXIT +trap 'rm -rf "$TEMP_DIR"' EXIT # Use rsync with .dockerignore patterns to simulate Docker's behavior cd "$PROJECT_ROOT" diff --git a/clients/agent-runtime/src/agent/classifier.rs b/clients/agent-runtime/src/agent/classifier.rs index 76c965a31..8f09fe839 100755 --- a/clients/agent-runtime/src/agent/classifier.rs +++ b/clients/agent-runtime/src/agent/classifier.rs @@ -1,5 +1,40 @@ use crate::config::schema::QueryClassificationConfig; +fn within_length_constraints( + len: usize, + min_length: Option, + max_length: Option, +) -> bool { + if let Some(min) = min_length { + if len < min { + return false; + } + } + + if let Some(max) = max_length { + if len > max { + return false; + } + } + + true +} + +fn has_keyword_or_pattern_match( + lower_message: &str, + message: &str, + keywords: &[String], + patterns: &[String], +) -> bool { + let keyword_hit = keywords + .iter() + .any(|keyword| lower_message.contains(&keyword.to_lowercase())); + let pattern_hit = patterns + .iter() + .any(|pattern| message.contains(pattern.as_str())); + keyword_hit || pattern_hit +} + /// Classify a user message against the configured rules and return the /// matching hint string, if any. /// @@ -17,29 +52,11 @@ pub fn classify(config: &QueryClassificationConfig, message: &str) -> Option max { - continue; - } + if !within_length_constraints(len, rule.min_length, rule.max_length) { + continue; } - // Check keywords (case-insensitive) and patterns (case-sensitive) - let keyword_hit = rule - .keywords - .iter() - .any(|kw: &String| lower.contains(&kw.to_lowercase())); - let pattern_hit = rule - .patterns - .iter() - .any(|pat: &String| message.contains(pat.as_str())); - - if keyword_hit || pattern_hit { + if has_keyword_or_pattern_match(&lower, message, &rule.keywords, &rule.patterns) { return Some(rule.hint.clone()); } } diff --git a/clients/agent-runtime/src/channels/mod.rs b/clients/agent-runtime/src/channels/mod.rs index 95ebd87b7..d168b1e8d 100755 --- a/clients/agent-runtime/src/channels/mod.rs +++ b/clients/agent-runtime/src/channels/mod.rs @@ -1195,75 +1195,83 @@ fn bind_telegram_identity(config: &Config, identity: &str) -> Result<()> { Ok(()) } -fn maybe_restart_managed_daemon_service() -> Result { - if cfg!(target_os = "macos") { - let home = directories::UserDirs::new() - .map(|u| u.home_dir().to_path_buf()) - .context("Could not find home directory")?; - let plist = home - .join("Library") - .join("LaunchAgents") - .join("com.corvus.daemon.plist"); - if !plist.exists() { - return Ok(false); - } - - let list_output = Command::new("launchctl") - .arg("list") - .output() - .context("Failed to query launchctl list")?; - let listed = String::from_utf8_lossy(&list_output.stdout); - if !listed.contains("com.corvus.daemon") { - return Ok(false); - } +fn maybe_restart_launchd_daemon_service() -> Result { + let home = directories::UserDirs::new() + .map(|u| u.home_dir().to_path_buf()) + .context("Could not find home directory")?; + let plist = home + .join("Library") + .join("LaunchAgents") + .join("com.corvus.daemon.plist"); + if !plist.exists() { + return Ok(false); + } + + let list_output = Command::new("launchctl") + .arg("list") + .output() + .context("Failed to query launchctl list")?; + let listed = String::from_utf8_lossy(&list_output.stdout); + if !listed.contains("com.corvus.daemon") { + return Ok(false); + } + + let _ = Command::new("launchctl") + .args(["stop", "com.corvus.daemon"]) + .output(); + let start_output = Command::new("launchctl") + .args(["start", "com.corvus.daemon"]) + .output() + .context("Failed to start launchd daemon service")?; + if !start_output.status.success() { + let stderr = String::from_utf8_lossy(&start_output.stderr); + anyhow::bail!("launchctl start failed: {}", stderr.trim()); + } + + Ok(true) +} - let _ = Command::new("launchctl") - .args(["stop", "com.corvus.daemon"]) - .output(); - let start_output = Command::new("launchctl") - .args(["start", "com.corvus.daemon"]) - .output() - .context("Failed to start launchd daemon service")?; - if !start_output.status.success() { - let stderr = String::from_utf8_lossy(&start_output.stderr); - anyhow::bail!("launchctl start failed: {}", stderr.trim()); - } +fn maybe_restart_systemd_daemon_service() -> Result { + let home = directories::UserDirs::new() + .map(|u| u.home_dir().to_path_buf()) + .context("Could not find home directory")?; + let unit_path: PathBuf = home + .join(".config") + .join("systemd") + .join("user") + .join("corvus.service"); + if !unit_path.exists() { + return Ok(false); + } + + let active_output = Command::new("systemctl") + .args(["--user", "is-active", "corvus.service"]) + .output() + .context("Failed to query systemd service state")?; + let state = String::from_utf8_lossy(&active_output.stdout); + if !state.trim().eq_ignore_ascii_case("active") { + return Ok(false); + } + + let restart_output = Command::new("systemctl") + .args(["--user", "restart", "corvus.service"]) + .output() + .context("Failed to restart systemd daemon service")?; + if !restart_output.status.success() { + let stderr = String::from_utf8_lossy(&restart_output.stderr); + anyhow::bail!("systemctl restart failed: {}", stderr.trim()); + } + + Ok(true) +} - return Ok(true); +fn maybe_restart_managed_daemon_service() -> Result { + if cfg!(target_os = "macos") { + return maybe_restart_launchd_daemon_service(); } if cfg!(target_os = "linux") { - let home = directories::UserDirs::new() - .map(|u| u.home_dir().to_path_buf()) - .context("Could not find home directory")?; - let unit_path: PathBuf = home - .join(".config") - .join("systemd") - .join("user") - .join("corvus.service"); - if !unit_path.exists() { - return Ok(false); - } - - let active_output = Command::new("systemctl") - .args(["--user", "is-active", "corvus.service"]) - .output() - .context("Failed to query systemd service state")?; - let state = String::from_utf8_lossy(&active_output.stdout); - if !state.trim().eq_ignore_ascii_case("active") { - return Ok(false); - } - - let restart_output = Command::new("systemctl") - .args(["--user", "restart", "corvus.service"]) - .output() - .context("Failed to restart systemd daemon service")?; - if !restart_output.status.success() { - let stderr = String::from_utf8_lossy(&restart_output.stderr); - anyhow::bail!("systemctl restart failed: {}", stderr.trim()); - } - - return Ok(true); + return maybe_restart_systemd_daemon_service(); } Ok(false) diff --git a/clients/agent-runtime/src/gateway/admin.rs b/clients/agent-runtime/src/gateway/admin.rs index c5620706c..fb8ce4e7d 100644 --- a/clients/agent-runtime/src/gateway/admin.rs +++ b/clients/agent-runtime/src/gateway/admin.rs @@ -1106,20 +1106,44 @@ fn apply_gateway_patch( return Ok(()); }; + apply_gateway_binding_patch(cfg, gateway)?; + apply_gateway_security_patch(cfg, gateway); + apply_gateway_limits_patch(cfg, gateway)?; + apply_gateway_idempotency_patch(cfg, gateway)?; + + Ok(()) +} + +fn apply_gateway_binding_patch( + cfg: &mut Config, + gateway: &AdminGatewayPatch, +) -> Result<(), AdminResponse> { if let Some(port) = gateway.port { ensure_non_zero_u16(port, "gateway.port must be in range [1, 65535]")?; cfg.gateway.port = port; } if let Some(host) = gateway.host.as_ref() { - let host = normalize_gateway_host(host)?; - cfg.gateway.host = host; + cfg.gateway.host = normalize_gateway_host(host)?; } + Ok(()) +} + +fn apply_gateway_security_patch(cfg: &mut Config, gateway: &AdminGatewayPatch) { if let Some(require_pairing) = gateway.require_pairing { cfg.gateway.require_pairing = require_pairing; } if let Some(allow_public_bind) = gateway.allow_public_bind { cfg.gateway.allow_public_bind = allow_public_bind; } + if let Some(trust_forwarded_headers) = gateway.trust_forwarded_headers { + cfg.gateway.trust_forwarded_headers = trust_forwarded_headers; + } +} + +fn apply_gateway_limits_patch( + cfg: &mut Config, + gateway: &AdminGatewayPatch, +) -> Result<(), AdminResponse> { if let Some(limit) = gateway.pair_rate_limit_per_minute { ensure_non_zero_u32(limit, "gateway.pair_rate_limit_per_minute must be >= 1")?; cfg.gateway.pair_rate_limit_per_minute = limit; @@ -1128,15 +1152,19 @@ fn apply_gateway_patch( ensure_non_zero_u32(limit, "gateway.webhook_rate_limit_per_minute must be >= 1")?; cfg.gateway.webhook_rate_limit_per_minute = limit; } - if let Some(trust_forwarded_headers) = gateway.trust_forwarded_headers { - cfg.gateway.trust_forwarded_headers = trust_forwarded_headers; - } if let Some(rate_limit_max_keys) = gateway.rate_limit_max_keys { cfg.gateway.rate_limit_max_keys = gateway::utils::normalize_max_keys( rate_limit_max_keys, cfg.gateway.rate_limit_max_keys, ); } + Ok(()) +} + +fn apply_gateway_idempotency_patch( + cfg: &mut Config, + gateway: &AdminGatewayPatch, +) -> Result<(), AdminResponse> { if let Some(idempotency_ttl_secs) = gateway.idempotency_ttl_secs { ensure_non_zero_u64( idempotency_ttl_secs, @@ -1254,29 +1282,9 @@ fn apply_web_search_patch( if let Some(enabled) = web_search.enabled { cfg.web_search.enabled = enabled; } - if let Some(provider) = web_search.provider.as_ref() { - let provider = provider.trim().to_ascii_lowercase(); - if provider != "duckduckgo" && provider != "brave" { - return Err(bad_request( - "web_search.provider must be one of: duckduckgo, brave", - )); - } - cfg.web_search.provider = provider; - } - if let Some(max_results) = web_search.max_results { - if !(1..=10).contains(&max_results) { - return Err(bad_request( - "web_search.max_results must be in range [1, 10]", - )); - } - cfg.web_search.max_results = max_results; - } - if let Some(timeout_secs) = web_search.timeout_secs { - if timeout_secs == 0 { - return Err(bad_request("web_search.timeout_secs must be >= 1")); - } - cfg.web_search.timeout_secs = timeout_secs; - } + apply_web_search_provider_patch(cfg, web_search.provider.as_deref())?; + apply_web_search_max_results_patch(cfg, web_search.max_results)?; + apply_web_search_timeout_patch(cfg, web_search.timeout_secs)?; if let Some(brave_api_key) = web_search.brave_api_key.as_ref() { apply_secret_update( &mut cfg.web_search.brave_api_key, @@ -1288,6 +1296,56 @@ fn apply_web_search_patch( Ok(()) } +fn apply_web_search_provider_patch( + cfg: &mut Config, + provider: Option<&str>, +) -> Result<(), AdminResponse> { + let Some(provider) = provider else { + return Ok(()); + }; + + let provider = provider.trim().to_ascii_lowercase(); + if provider != "duckduckgo" && provider != "brave" { + return Err(bad_request( + "web_search.provider must be one of: duckduckgo, brave", + )); + } + cfg.web_search.provider = provider; + Ok(()) +} + +fn apply_web_search_max_results_patch( + cfg: &mut Config, + max_results: Option, +) -> Result<(), AdminResponse> { + let Some(max_results) = max_results else { + return Ok(()); + }; + + if !(1..=10).contains(&max_results) { + return Err(bad_request( + "web_search.max_results must be in range [1, 10]", + )); + } + cfg.web_search.max_results = max_results; + Ok(()) +} + +fn apply_web_search_timeout_patch( + cfg: &mut Config, + timeout_secs: Option, +) -> Result<(), AdminResponse> { + let Some(timeout_secs) = timeout_secs else { + return Ok(()); + }; + + if timeout_secs == 0 { + return Err(bad_request("web_search.timeout_secs must be >= 1")); + } + cfg.web_search.timeout_secs = timeout_secs; + Ok(()) +} + fn apply_browser_patch( cfg: &mut Config, browser: Option<&AdminBrowserPatch>, @@ -1378,43 +1436,77 @@ fn apply_surreal_memory_patch( Ok(()) } +fn default_webhook_config() -> crate::config::schema::WebhookConfig { + crate::config::schema::WebhookConfig { + port: 3000, + secret: None, + } +} + +fn ensure_webhook_config(cfg: &mut Config) { + if cfg.channels_config.webhook.is_none() { + cfg.channels_config.webhook = Some(default_webhook_config()); + } +} + +fn apply_webhook_enabled_patch( + cfg: &mut Config, + enabled: Option, +) -> Result<(), AdminResponse> { + let Some(enabled) = enabled else { + return Ok(()); + }; + + if enabled { + ensure_webhook_config(cfg); + return Ok(()); + } + + cfg.channels_config.webhook = None; + Ok(()) +} + +fn apply_webhook_port_patch( + webhook: &mut crate::config::schema::WebhookConfig, + port: Option, +) -> Result<(), AdminResponse> { + let Some(port) = port else { + return Ok(()); + }; + if port == 0 { + return Err(bad_request( + "channels.webhook.port must be in range [1, 65535]", + )); + } + webhook.port = port; + Ok(()) +} + +fn apply_webhook_secret_patch( + webhook: &mut crate::config::schema::WebhookConfig, + secret: Option<&AdminSecretUpdate>, +) -> Result<(), AdminResponse> { + let Some(secret) = secret else { + return Ok(()); + }; + apply_secret_update(&mut webhook.secret, secret, "channels.webhook.secret") +} + fn apply_webhook_patch(cfg: &mut Config, patch: &AdminWebhookPatch) -> Result<(), AdminResponse> { - if let Some(enabled) = patch.enabled { - if enabled && cfg.channels_config.webhook.is_none() { - cfg.channels_config.webhook = Some(crate::config::schema::WebhookConfig { - port: 3000, - secret: None, - }); - } - if !enabled { - cfg.channels_config.webhook = None; - return Ok(()); - } + apply_webhook_enabled_patch(cfg, patch.enabled)?; + if patch.enabled == Some(false) { + return Ok(()); } if patch.port.is_none() && patch.secret.is_none() { return Ok(()); } - if cfg.channels_config.webhook.is_none() { - cfg.channels_config.webhook = Some(crate::config::schema::WebhookConfig { - port: 3000, - secret: None, - }); - } + ensure_webhook_config(cfg); if let Some(webhook) = cfg.channels_config.webhook.as_mut() { - if let Some(port) = patch.port { - if port == 0 { - return Err(bad_request( - "channels.webhook.port must be in range [1, 65535]", - )); - } - webhook.port = port; - } - if let Some(secret) = patch.secret.as_ref() { - apply_secret_update(&mut webhook.secret, secret, "channels.webhook.secret")?; - } + apply_webhook_port_patch(webhook, patch.port)?; + apply_webhook_secret_patch(webhook, patch.secret.as_ref())?; } Ok(()) diff --git a/clients/agent-runtime/src/tools/browser.rs b/clients/agent-runtime/src/tools/browser.rs index 40ab31cff..f5a744fc1 100755 --- a/clients/agent-runtime/src/tools/browser.rs +++ b/clients/agent-runtime/src/tools/browser.rs @@ -507,6 +507,78 @@ impl BrowserTool { parts.iter().map(|part| (*part).to_string()).collect() } + fn snapshot_command_args( + interactive_only: bool, + compact: bool, + depth: Option, + ) -> Vec { + let mut args = Self::to_owned_args(&["snapshot"]); + if interactive_only { + args.push("-i".into()); + } + if compact { + args.push("-c".into()); + } + if let Some(d) = depth { + args.push("-d".into()); + args.push(d.to_string()); + } + args + } + + fn screenshot_command_args(path: Option, full_page: bool) -> Vec { + let mut args = Self::to_owned_args(&["screenshot"]); + if let Some(path_str) = path { + args.push(path_str); + } + if full_page { + args.push("--full".into()); + } + args + } + + fn wait_command_args( + selector: Option, + ms: Option, + text: Option, + ) -> Vec { + let mut args = Self::to_owned_args(&["wait"]); + if let Some(sel) = selector { + args.push(sel); + return args; + } + if let Some(millis) = ms { + args.push(millis.to_string()); + return args; + } + if let Some(wait_text) = text { + args.push("--text".into()); + args.push(wait_text); + } + args + } + + fn scroll_command_args(direction: String, pixels: Option) -> Vec { + let mut args = vec!["scroll".into(), direction]; + if let Some(px) = pixels { + args.push(px.to_string()); + } + args + } + + fn find_command_args( + by: String, + value: String, + action: String, + fill_value: Option, + ) -> Vec { + let mut args = vec!["find".into(), by, value, action]; + if let Some(fv) = fill_value { + args.push(fv); + } + args + } + fn command_for_agent_browser_action( &self, action: BrowserAction, @@ -520,20 +592,11 @@ impl BrowserTool { interactive_only, compact, depth, - } => { - let mut args = Self::to_owned_args(&["snapshot"]); - if interactive_only { - args.push("-i".into()); - } - if compact { - args.push("-c".into()); - } - if let Some(d) = depth { - args.push("-d".into()); - args.push(d.to_string()); - } - Ok(args) - } + } => Ok(Self::snapshot_command_args( + interactive_only, + compact, + depth, + )), BrowserAction::Click { selector } => Ok(vec!["click".into(), selector]), BrowserAction::Fill { selector, value } => Ok(vec!["fill".into(), selector, value]), BrowserAction::Type { selector, text } => Ok(vec!["type".into(), selector, text]), @@ -543,35 +606,15 @@ impl BrowserTool { BrowserAction::GetTitle => Ok(Self::to_owned_args(&["get", "title"])), BrowserAction::GetUrl => Ok(Self::to_owned_args(&["get", "url"])), BrowserAction::Screenshot { path, full_page } => { - let mut args = Self::to_owned_args(&["screenshot"]); - if let Some(path_str) = path { - args.push(path_str); - } - if full_page { - args.push("--full".into()); - } - Ok(args) + Ok(Self::screenshot_command_args(path, full_page)) } BrowserAction::Wait { selector, ms, text } => { - let mut args = Self::to_owned_args(&["wait"]); - if let Some(sel) = selector { - args.push(sel); - } else if let Some(millis) = ms { - args.push(millis.to_string()); - } else if let Some(wait_text) = text { - args.push("--text".into()); - args.push(wait_text); - } - Ok(args) + Ok(Self::wait_command_args(selector, ms, text)) } BrowserAction::Press { key } => Ok(vec!["press".into(), key]), BrowserAction::Hover { selector } => Ok(vec!["hover".into(), selector]), BrowserAction::Scroll { direction, pixels } => { - let mut args = vec!["scroll".into(), direction]; - if let Some(px) = pixels { - args.push(px.to_string()); - } - Ok(args) + Ok(Self::scroll_command_args(direction, pixels)) } BrowserAction::IsVisible { selector } => { Ok(Self::to_owned_args(&["is", "visible", selector.as_str()])) @@ -582,13 +625,7 @@ impl BrowserTool { value, action, fill_value, - } => { - let mut args = vec!["find".into(), by, value, action]; - if let Some(fv) = fill_value { - args.push(fv); - } - Ok(args) - } + } => Ok(Self::find_command_args(by, value, action, fill_value)), } } @@ -1771,156 +1808,155 @@ mod native_backend { /// Parse a JSON `args` object into a typed `BrowserAction`. fn parse_browser_action(action_str: &str, args: &Value) -> anyhow::Result { match action_str { - "open" => { - let url = args - .get("url") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'url' for open action"))?; - Ok(BrowserAction::Open { url: url.into() }) - } - "snapshot" => Ok(BrowserAction::Snapshot { - interactive_only: args - .get("interactive_only") - .and_then(serde_json::Value::as_bool) - .unwrap_or(true), - compact: args - .get("compact") - .and_then(serde_json::Value::as_bool) - .unwrap_or(true), - depth: args - .get("depth") - .and_then(serde_json::Value::as_u64) - .map(|d| u32::try_from(d).unwrap_or(u32::MAX)), - }), - "click" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for click"))?; - Ok(BrowserAction::Click { - selector: selector.into(), - }) - } + "open" => parse_open_action(args), + "snapshot" => Ok(parse_snapshot_action(args)), + "click" | "get_text" | "hover" | "is_visible" => parse_selector_action(action_str, args), + "fill" | "type" => parse_selector_value_action(action_str, args), + "get_title" | "get_url" | "close" => parse_simple_action(action_str), + "screenshot" => Ok(parse_screenshot_action(args)), + "wait" => Ok(parse_wait_action(args)), + "press" => parse_press_action(args), + "scroll" => parse_scroll_action(args), + "find" => parse_find_action(args), + other => anyhow::bail!("Unsupported browser action: {other}"), + } +} + +fn parse_open_action(args: &Value) -> anyhow::Result { + let url = required_action_str(args, "url", "open action")?; + Ok(BrowserAction::Open { url: url.into() }) +} + +fn parse_snapshot_action(args: &Value) -> BrowserAction { + BrowserAction::Snapshot { + interactive_only: args + .get("interactive_only") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true), + compact: args + .get("compact") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true), + depth: args + .get("depth") + .and_then(serde_json::Value::as_u64) + .map(|d| u32::try_from(d).unwrap_or(u32::MAX)), + } +} + +fn parse_selector_action(action: &str, args: &Value) -> anyhow::Result { + let selector = required_action_str(args, "selector", action)?; + + let parsed = match action { + "click" => BrowserAction::Click { + selector: selector.into(), + }, + "get_text" => BrowserAction::GetText { + selector: selector.into(), + }, + "hover" => BrowserAction::Hover { + selector: selector.into(), + }, + "is_visible" => BrowserAction::IsVisible { + selector: selector.into(), + }, + _ => anyhow::bail!("Unsupported browser action: {action}"), + }; + + Ok(parsed) +} + +fn parse_selector_value_action(action: &str, args: &Value) -> anyhow::Result { + let selector = required_action_str(args, "selector", action)?; + + let parsed = match action { "fill" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for fill"))?; - let value = args - .get("value") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'value' for fill"))?; - Ok(BrowserAction::Fill { + let value = required_action_str(args, "value", "fill")?; + BrowserAction::Fill { selector: selector.into(), value: value.into(), - }) + } } "type" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for type"))?; - let text = args - .get("text") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'text' for type"))?; - Ok(BrowserAction::Type { + let text = required_action_str(args, "text", "type")?; + BrowserAction::Type { selector: selector.into(), text: text.into(), - }) - } - "get_text" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for get_text"))?; - Ok(BrowserAction::GetText { - selector: selector.into(), - }) + } } + _ => anyhow::bail!("Unsupported browser action: {action}"), + }; + + Ok(parsed) +} + +fn parse_simple_action(action: &str) -> anyhow::Result { + match action { "get_title" => Ok(BrowserAction::GetTitle), "get_url" => Ok(BrowserAction::GetUrl), - "screenshot" => Ok(BrowserAction::Screenshot { - path: args.get("path").and_then(|v| v.as_str()).map(String::from), - full_page: args - .get("full_page") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false), - }), - "wait" => Ok(BrowserAction::Wait { - selector: args - .get("selector") - .and_then(|v| v.as_str()) - .map(String::from), - ms: args.get("ms").and_then(serde_json::Value::as_u64), - text: args.get("text").and_then(|v| v.as_str()).map(String::from), - }), - "press" => { - let key = args - .get("key") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'key' for press"))?; - Ok(BrowserAction::Press { key: key.into() }) - } - "hover" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for hover"))?; - Ok(BrowserAction::Hover { - selector: selector.into(), - }) - } - "scroll" => { - let direction = args - .get("direction") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'direction' for scroll"))?; - Ok(BrowserAction::Scroll { - direction: direction.into(), - pixels: args - .get("pixels") - .and_then(serde_json::Value::as_u64) - .map(|p| u32::try_from(p).unwrap_or(u32::MAX)), - }) - } - "is_visible" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for is_visible"))?; - Ok(BrowserAction::IsVisible { - selector: selector.into(), - }) - } "close" => Ok(BrowserAction::Close), - "find" => { - let by = args - .get("by") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'by' for find"))?; - let value = args - .get("value") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'value' for find"))?; - let action = args - .get("find_action") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'find_action' for find"))?; - Ok(BrowserAction::Find { - by: by.into(), - value: value.into(), - action: action.into(), - fill_value: args - .get("fill_value") - .and_then(|v| v.as_str()) - .map(String::from), - }) - } - other => anyhow::bail!("Unsupported browser action: {other}"), + _ => anyhow::bail!("Unsupported browser action: {action}"), } } +fn parse_screenshot_action(args: &Value) -> BrowserAction { + BrowserAction::Screenshot { + path: args.get("path").and_then(|v| v.as_str()).map(String::from), + full_page: args + .get("full_page") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + } +} + +fn parse_wait_action(args: &Value) -> BrowserAction { + BrowserAction::Wait { + selector: args + .get("selector") + .and_then(|v| v.as_str()) + .map(String::from), + ms: args.get("ms").and_then(serde_json::Value::as_u64), + text: args.get("text").and_then(|v| v.as_str()).map(String::from), + } +} + +fn parse_press_action(args: &Value) -> anyhow::Result { + let key = required_action_str(args, "key", "press")?; + Ok(BrowserAction::Press { key: key.into() }) +} + +fn parse_scroll_action(args: &Value) -> anyhow::Result { + let direction = required_action_str(args, "direction", "scroll")?; + Ok(BrowserAction::Scroll { + direction: direction.into(), + pixels: args + .get("pixels") + .and_then(serde_json::Value::as_u64) + .map(|p| u32::try_from(p).unwrap_or(u32::MAX)), + }) +} + +fn parse_find_action(args: &Value) -> anyhow::Result { + let by = required_action_str(args, "by", "find")?; + let value = required_action_str(args, "value", "find")?; + let action = required_action_str(args, "find_action", "find")?; + Ok(BrowserAction::Find { + by: by.into(), + value: value.into(), + action: action.into(), + fill_value: args + .get("fill_value") + .and_then(|v| v.as_str()) + .map(String::from), + }) +} + +fn required_action_str<'a>(args: &'a Value, key: &str, action: &str) -> anyhow::Result<&'a str> { + args.get(key) + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("Missing '{key}' for {action}")) +} + // ── Helper functions ───────────────────────────────────────────── fn is_supported_browser_action(action: &str) -> bool { diff --git a/clients/agent-runtime/src/tools/git_operations.rs b/clients/agent-runtime/src/tools/git_operations.rs index 01a6a20e0..fea918d50 100755 --- a/clients/agent-runtime/src/tools/git_operations.rs +++ b/clients/agent-runtime/src/tools/git_operations.rs @@ -95,27 +95,12 @@ impl GitOperationsTool { untracked: &mut Vec, branch: &mut String, ) { - if line.starts_with("# branch.head ") { - *branch = line.trim_start_matches("# branch.head ").to_string(); + if Self::try_parse_branch_head(line, branch) { return; } if let Some(rest) = line.strip_prefix("1 ") { - // Ordinary changed entry - let mut parts = rest.splitn(3, ' '); - if let (Some(staging), Some(path)) = (parts.next(), parts.next()) { - if !staging.is_empty() { - let status_char = staging.chars().next().unwrap_or(' '); - if status_char != '.' && status_char != ' ' { - staged.push(json!({"path": path, "status": status_char})); - } - - let status_char = staging.chars().nth(1).unwrap_or(' '); - if status_char != '.' && status_char != ' ' { - unstaged.push(json!({"path": path, "status": status_char})); - } - } - } + Self::parse_ordinary_changed_entry(rest, staged, unstaged); return; } @@ -124,6 +109,44 @@ impl GitOperationsTool { } } + fn try_parse_branch_head(line: &str, branch: &mut String) -> bool { + if !line.starts_with("# branch.head ") { + return false; + } + + *branch = line.trim_start_matches("# branch.head ").to_string(); + true + } + + fn push_status_if_changed(target: &mut Vec, status_char: char, path: &str) { + if status_char == '.' || status_char == ' ' { + return; + } + + target.push(json!({"path": path, "status": status_char})); + } + + fn parse_ordinary_changed_entry( + rest: &str, + staged: &mut Vec, + unstaged: &mut Vec, + ) { + let mut parts = rest.splitn(3, ' '); + let (Some(staging), Some(path)) = (parts.next(), parts.next()) else { + return; + }; + + if staging.is_empty() { + return; + } + + let staged_status = staging.chars().next().unwrap_or(' '); + Self::push_status_if_changed(staged, staged_status, path); + + let unstaged_status = staging.chars().nth(1).unwrap_or(' '); + Self::push_status_if_changed(unstaged, unstaged_status, path); + } + fn diff_line_type(line: &str) -> &'static str { if line.starts_with('+') { "add" diff --git a/clients/agent-runtime/src/tools/mod.rs b/clients/agent-runtime/src/tools/mod.rs index d1bdc37ae..6a65cb3be 100755 --- a/clients/agent-runtime/src/tools/mod.rs +++ b/clients/agent-runtime/src/tools/mod.rs @@ -102,6 +102,158 @@ pub fn default_tools_with_runtime( ] } +fn add_browser_tools( + tools: &mut Vec>, + security: &Arc, + browser_config: &crate::config::BrowserConfig, +) { + if !browser_config.enabled { + return; + } + + tools.push(Box::new(BrowserOpenTool::new( + security.clone(), + browser_config.allowed_domains.clone(), + ))); + tools.push(Box::new(BrowserTool::new_with_backend( + security.clone(), + browser_config.allowed_domains.clone(), + browser_config.session_name.clone(), + browser_config.backend.clone(), + browser_config.native_headless, + browser_config.native_webdriver_url.clone(), + browser_config.native_chrome_path.clone(), + ComputerUseConfig { + endpoint: browser_config.computer_use.endpoint.clone(), + api_key: browser_config.computer_use.api_key.clone(), + timeout_ms: browser_config.computer_use.timeout_ms, + allow_remote_endpoint: browser_config.computer_use.allow_remote_endpoint, + window_allowlist: browser_config.computer_use.window_allowlist.clone(), + max_coordinate_x: browser_config.computer_use.max_coordinate_x, + max_coordinate_y: browser_config.computer_use.max_coordinate_y, + }, + ))); +} + +fn add_http_request_tool( + tools: &mut Vec>, + security: &Arc, + http_config: &crate::config::HttpRequestConfig, +) { + if !http_config.enabled { + return; + } + + tools.push(Box::new(HttpRequestTool::new( + security.clone(), + http_config.allowed_domains.clone(), + http_config.max_response_size, + http_config.timeout_secs, + ))); +} + +fn add_web_search_tool( + tools: &mut Vec>, + security: &Arc, + root_config: &crate::config::Config, +) { + if !root_config.web_search.enabled { + return; + } + + tools.push(Box::new(WebSearchTool::new( + security.clone(), + root_config.web_search.provider.clone(), + root_config.web_search.brave_api_key.clone(), + root_config.web_search.max_results, + root_config.web_search.timeout_secs, + ))); +} + +fn add_composio_tool( + tools: &mut Vec>, + security: &Arc, + composio_key: Option<&str>, + composio_entity_id: Option<&str>, +) { + let Some(key) = composio_key else { + return; + }; + if key.is_empty() { + return; + } + + tools.push(Box::new(ComposioTool::new( + key, + composio_entity_id, + security.clone(), + ))); +} + +fn add_delegate_tool( + tools: &mut Vec>, + security: &Arc, + agents: &HashMap, + fallback_api_key: Option<&str>, +) { + if agents.is_empty() { + return; + } + + let delegate_agents: HashMap = agents + .iter() + .map(|(name, cfg)| (name.clone(), cfg.clone())) + .collect(); + let delegate_fallback_credential = fallback_api_key.and_then(|value| { + let trimmed_value = value.trim(); + (!trimmed_value.is_empty()).then(|| trimmed_value.to_owned()) + }); + tools.push(Box::new(DelegateTool::new( + delegate_agents, + delegate_fallback_credential, + security.clone(), + ))); +} + +#[cfg(feature = "mcp-runtime")] +fn extend_with_mcp_tools(tools: &mut Vec>, root_config: &crate::config::Config) { + if !root_config.mcp.enabled { + return; + } + + match mcp::discover_tools(&root_config.mcp) { + Ok(mcp_tools) => { + let mut existing_names: HashSet = + tools.iter().map(|tool| tool.name().to_string()).collect(); + let mut detected_collision: Option = None; + + for mcp_tool in &mcp_tools { + let name = mcp_tool.name(); + if !existing_names.insert(name.to_string()) { + detected_collision = Some(name.to_string()); + break; + } + } + + if let Some(collision) = detected_collision { + tracing::warn!( + collision = %collision, + "MCP registration skipped due to tool-name collision" + ); + } else { + tools.extend(mcp_tools); + } + } + Err(error) => { + let redacted = redact_runtime_error(&error.to_string()); + tracing::warn!( + error = %redacted, + "mcp.enabled is true but MCP tool discovery failed" + ); + } + } +} + /// Create full tool registry including memory tools and optional Composio #[allow(clippy::implicit_hasher, clippy::too_many_arguments)] pub fn all_tools( @@ -173,118 +325,20 @@ pub fn all_tools_with_runtime( )), ]; - if browser_config.enabled { - // Add legacy browser_open tool for simple URL opening - tools.push(Box::new(BrowserOpenTool::new( - security.clone(), - browser_config.allowed_domains.clone(), - ))); - // Add full browser automation tool (pluggable backend) - tools.push(Box::new(BrowserTool::new_with_backend( - security.clone(), - browser_config.allowed_domains.clone(), - browser_config.session_name.clone(), - browser_config.backend.clone(), - browser_config.native_headless, - browser_config.native_webdriver_url.clone(), - browser_config.native_chrome_path.clone(), - ComputerUseConfig { - endpoint: browser_config.computer_use.endpoint.clone(), - api_key: browser_config.computer_use.api_key.clone(), - timeout_ms: browser_config.computer_use.timeout_ms, - allow_remote_endpoint: browser_config.computer_use.allow_remote_endpoint, - window_allowlist: browser_config.computer_use.window_allowlist.clone(), - max_coordinate_x: browser_config.computer_use.max_coordinate_x, - max_coordinate_y: browser_config.computer_use.max_coordinate_y, - }, - ))); - } - - if http_config.enabled { - tools.push(Box::new(HttpRequestTool::new( - security.clone(), - http_config.allowed_domains.clone(), - http_config.max_response_size, - http_config.timeout_secs, - ))); - } - - // Web search tool (enabled by default for GLM and other models) - if root_config.web_search.enabled { - tools.push(Box::new(WebSearchTool::new( - security.clone(), - root_config.web_search.provider.clone(), - root_config.web_search.brave_api_key.clone(), - root_config.web_search.max_results, - root_config.web_search.timeout_secs, - ))); - } + add_browser_tools(&mut tools, security, browser_config); + add_http_request_tool(&mut tools, security, http_config); + add_web_search_tool(&mut tools, security, root_config); // Vision tools are always available tools.push(Box::new(ScreenshotTool::new(security.clone()))); tools.push(Box::new(ImageInfoTool::new(security.clone()))); - if let Some(key) = composio_key { - if !key.is_empty() { - tools.push(Box::new(ComposioTool::new( - key, - composio_entity_id, - security.clone(), - ))); - } - } + add_composio_tool(&mut tools, security, composio_key, composio_entity_id); - // Add delegation tool when agents are configured - if !agents.is_empty() { - let delegate_agents: HashMap = agents - .iter() - .map(|(name, cfg)| (name.clone(), cfg.clone())) - .collect(); - let delegate_fallback_credential = fallback_api_key.and_then(|value| { - let trimmed_value = value.trim(); - (!trimmed_value.is_empty()).then(|| trimmed_value.to_owned()) - }); - tools.push(Box::new(DelegateTool::new( - delegate_agents, - delegate_fallback_credential, - security.clone(), - ))); - } + add_delegate_tool(&mut tools, security, agents, fallback_api_key); #[cfg(feature = "mcp-runtime")] - if root_config.mcp.enabled { - match mcp::discover_tools(&root_config.mcp) { - Ok(mcp_tools) => { - let mut existing_names: HashSet = - tools.iter().map(|tool| tool.name().to_string()).collect(); - let mut detected_collision: Option = None; - - for mcp_tool in &mcp_tools { - let name = mcp_tool.name(); - if !existing_names.insert(name.to_string()) { - detected_collision = Some(name.to_string()); - break; - } - } - - if let Some(collision) = detected_collision { - tracing::warn!( - collision = %collision, - "MCP registration skipped due to tool-name collision" - ); - } else { - tools.extend(mcp_tools); - } - } - Err(error) => { - let redacted = redact_runtime_error(&error.to_string()); - tracing::warn!( - error = %redacted, - "mcp.enabled is true but MCP tool discovery failed" - ); - } - } - } + extend_with_mcp_tools(&mut tools, root_config); tools } diff --git a/clients/web/apps/dashboard/Dockerfile b/clients/web/apps/dashboard/Dockerfile new file mode 100644 index 000000000..82be17575 --- /dev/null +++ b/clients/web/apps/dashboard/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-alpine AS build + +WORKDIR /app + +RUN corepack enable + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/dashboard/package.json ./apps/dashboard/package.json +COPY packages/locales/package.json ./packages/locales/package.json +COPY packages/shared/package.json ./packages/shared/package.json +COPY packages/ui/package.json ./packages/ui/package.json + +RUN pnpm install --frozen-lockfile + +COPY apps/dashboard ./apps/dashboard +COPY packages/locales ./packages/locales +COPY packages/shared ./packages/shared +COPY packages/ui ./packages/ui + +RUN pnpm --filter @corvus/dashboard run build + +FROM nginxinc/nginx-unprivileged:1.27-alpine AS runtime + +COPY --from=build /app/apps/dashboard/dist /usr/share/nginx/html + +EXPOSE 8080 diff --git a/clients/web/apps/dashboard/README.md b/clients/web/apps/dashboard/README.md index 23766dcf1..545666029 100644 --- a/clients/web/apps/dashboard/README.md +++ b/clients/web/apps/dashboard/README.md @@ -17,3 +17,13 @@ pnpm dev:dashboard ``` Dashboard corre en . + +## Docker (local-first) + +```bash +# Desde clients/agent-runtime +docker compose --profile dashboard up -d +``` + +Luego abre , conecta al gateway en y completa el +pairing en `/pair` para obtener bearer token. diff --git a/clients/web/packages/locales/src/parity.spec.ts b/clients/web/packages/locales/src/parity.spec.ts index ca6998c5d..6c15c9f26 100644 --- a/clients/web/packages/locales/src/parity.spec.ts +++ b/clients/web/packages/locales/src/parity.spec.ts @@ -2,15 +2,19 @@ import { describe, expect, it } from "vitest"; import en from "./en.json"; import es from "./es.json"; -function flatten(obj: Record, prefix = ""): Record { - let result: Record = {}; +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function flatten( + obj: Record, + prefix = "", + result: Record = Object.create(null) as Record, +): Record { for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; - if (value && typeof value === "object" && !Array.isArray(value)) { - result = { - ...result, - ...flatten(value as Record, fullKey), - }; + if (isRecord(value)) { + flatten(value, fullKey, result); } else { result[fullKey] = String(value); } @@ -19,13 +23,31 @@ function flatten(obj: Record, prefix = ""): Record= 0) { + if (index - start > 1) { + placeholders.push(text.slice(start, index + 1)); + } + start = -1; + } + } + + return placeholders.sort(); } describe("Locale Parity Guard", () => { - const flattenedEs = flatten(es as unknown as Record); - const flattenedEn = flatten(en as unknown as Record); + const flattenedEs = flatten(es); + const flattenedEn = flatten(en); it("has identical sets of keys between Spanish and English", () => { const esKeys = Object.keys(flattenedEs).sort(); @@ -36,7 +58,7 @@ describe("Locale Parity Guard", () => { it("has matching placeholders for all shared keys", () => { for (const key of Object.keys(flattenedEs)) { - if (flattenedEn[key]) { + if (Object.prototype.hasOwnProperty.call(flattenedEn, key)) { const esPlaceholders = extractPlaceholders(flattenedEs[key]); const enPlaceholders = extractPlaceholders(flattenedEn[key]); diff --git a/clients/web/packages/shared/index.ts b/clients/web/packages/shared/index.ts index 72711606e..d4d6fac5d 100644 --- a/clients/web/packages/shared/index.ts +++ b/clients/web/packages/shared/index.ts @@ -1,16 +1,2 @@ -// Shared package entry point -// Export components, utilities, and types here - -// Components -// export { Button } from './components/Button'; -// export { Card } from './components/Card'; - -// Utils -// export { formatDate } from './utils/date'; -// export { cn } from './utils/cn'; - -// Types -// export type { User, Config } from './types'; - // Placeholder export export const version = '0.1.2'; diff --git a/dev/README.md b/dev/README.md index 1f8daf85e..949fbb041 100755 --- a/dev/README.md +++ b/dev/README.md @@ -25,6 +25,14 @@ Run all commands from the repository root using the helper script: ``` Builds the agent from source and starts both containers. +To start with the web dashboard too: + +```bash +./dev/cli.sh up-dashboard +``` + +Dashboard URL: + ### Provider Configuration (Per Developer) The dev stack is provider-agnostic. Choose provider via shell environment before `./dev/cli.sh up`. @@ -72,6 +80,12 @@ Use this to act as the "user" or "environment" the agent interacts with. corvus --version ``` +If you changed dashboard code and want to rebuild only that image: + +```bash +./dev/cli.sh build-dashboard +``` + ### 5. Persistence & Shared Workspace The local `playground/` directory (in repo root) is mounted as the shared workspace: - **Agent**: `/corvus-data/workspace` diff --git a/dev/cli.sh b/dev/cli.sh index bbcd16e2e..4967120d7 100755 --- a/dev/cli.sh +++ b/dev/cli.sh @@ -9,7 +9,7 @@ elif [[ -f "docker-compose.yml" ]] && [[ "$(basename "$(pwd)")" == "dev" ]]; the BASE_DIR="." HOST_TARGET_DIR="../clients/agent-runtime/target" else - echo "❌ Error: Run this script from the project root or dev/ directory." + echo "❌ Error: Run this script from the project root or dev/ directory." >&2 exit 1 fi @@ -33,6 +33,8 @@ function ensure_config { # Copy template cat "$BASE_DIR/config.template.toml" > "$CONFIG_FILE" fi + + return 0 } function print_help { @@ -40,13 +42,17 @@ function print_help { echo "Usage: ./dev/cli.sh [command]" echo "" echo "Commands:" - echo -e " ${GREEN}up${NC} Start dev environment (Agent + Sandbox)" + echo -e " ${GREEN}up${NC} Start dev environment (Agent + Sandbox)" + echo -e " ${GREEN}up-dashboard${NC} Start dev environment + Dashboard" echo -e " ${GREEN}down${NC} Stop containers" echo -e " ${GREEN}shell${NC} Enter Sandbox (Ubuntu)" echo -e " ${GREEN}agent${NC} Enter Agent (Corvus CLI)" echo -e " ${GREEN}logs${NC} View logs" - echo -e " ${GREEN}build${NC} Rebuild images" + echo -e " ${GREEN}build${NC} Rebuild agent + sandbox images" + echo -e " ${GREEN}build-dashboard${NC} Rebuild dashboard image" echo -e " ${GREEN}clean${NC} Stop and wipe workspace data" + + return 0 } if [[ -z "$1" ]]; then @@ -66,6 +72,17 @@ case "$1" in echo -e " - Config: $HOST_TARGET_DIR/.corvus/config.toml (Edit locally to apply changes)" ;; + up-dashboard) + ensure_config + echo -e "${GREEN}🚀 Starting Dev Environment (with Dashboard)...${NC}" + docker compose -f "$COMPOSE_FILE" --profile dashboard up -d + echo -e "${GREEN}✅ Environment is running!${NC}" + echo -e " - Agent: http://127.0.0.1:3000" + echo -e " - Dashboard: http://127.0.0.1:4324" + echo -e " - Sandbox: running (background)" + echo -e " - Config: $HOST_TARGET_DIR/.corvus/config.toml (Edit locally to apply changes)" + ;; + down) echo -e "${YELLOW}🛑 Stopping services...${NC}" docker compose -f "$COMPOSE_FILE" down @@ -94,9 +111,15 @@ case "$1" in echo -e "${GREEN}✅ Rebuild complete.${NC}" ;; + build-dashboard) + echo -e "${YELLOW}🔨 Rebuilding dashboard image...${NC}" + docker compose -f "$COMPOSE_FILE" --profile dashboard build dashboard-dev + echo -e "${GREEN}✅ Dashboard rebuild complete.${NC}" + ;; + clean) echo -e "${RED}⚠️ WARNING: This will delete '$HOST_TARGET_DIR/.corvus' data and Docker volumes.${NC}" - read -p "Are you sure? (y/N) " -n 1 -r + read -r -n 1 -p "Are you sure? (y/N) " REPLY echo if [[ $REPLY =~ ^[Yy]$ ]]; then docker compose -f "$COMPOSE_FILE" down -v diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 0e522aec5..b09efa898 100755 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -54,6 +54,22 @@ services: networks: - dev-net + # ── Optional Dashboard (Local-first) ── + # Build Vue dashboard and serve static dist via unprivileged nginx. + dashboard-dev: + build: + context: ../clients/web + dockerfile: apps/dashboard/Dockerfile + container_name: corvus-dashboard-dev + restart: unless-stopped + profiles: ["dashboard"] + depends_on: + - corvus-dev + ports: + - "127.0.0.1:4324:8080" + networks: + - dev-net + networks: dev-net: driver: bridge diff --git a/sync-version-with-tag.sh b/sync-version-with-tag.sh index 897efead7..702ad3096 100755 --- a/sync-version-with-tag.sh +++ b/sync-version-with-tag.sh @@ -34,6 +34,7 @@ has_json_version_key() { if (!found) exit 1 } ' "$file" + return $? } add_json_version_target() { @@ -134,6 +135,7 @@ update_toml_string_key() { exit 1 } write_if_changed "$file" "$temp_file" + return $? } update_json_string_key() { From 3792cfda40341b6247480bdb071888e321dbe9b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:41:45 +0100 Subject: [PATCH 2/6] feat: update config bindings to use value properties for improved reactivity --- README.md | 2 +- clients/web/apps/dashboard/Dockerfile | 4 +-- clients/web/apps/dashboard/src/App.vue | 36 +++++++++++++------------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 1de31585f..f5f408f2c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ [![Build Status](https://github.com/dallay/corvus/actions/workflows/pull-request-check.yml/badge.svg)](https://github.com/dallay/corvus/actions/workflows/pull-request-check.yml) [![codecov](https://codecov.io/gh/dallay/corvus/graph/badge.svg?token=N4THEP2OF1)](https://codecov.io/gh/dallay/corvus) [![License](https://img.shields.io/github/license/dallay/corvus?color=blue)](LICENSE) -[![Version](https://img.shields.io/badge/version-0.1.14-blue.svg)](gradle.properties) +[![Version](https://img.shields.io/github/v/tag/dallay/corvus?sort=semver&label=version)](https://github.com/dallay/corvus/tags) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/dallay/corvus/compare) ## 🛡️ Code Quality (SonarCloud) diff --git a/clients/web/apps/dashboard/Dockerfile b/clients/web/apps/dashboard/Dockerfile index 82be17575..b2f0ce6a8 100644 --- a/clients/web/apps/dashboard/Dockerfile +++ b/clients/web/apps/dashboard/Dockerfile @@ -12,14 +12,14 @@ COPY packages/locales/package.json ./packages/locales/package.json COPY packages/shared/package.json ./packages/shared/package.json COPY packages/ui/package.json ./packages/ui/package.json -RUN pnpm install --frozen-lockfile +RUN pnpm install --frozen-lockfile --node-linker=hoisted COPY apps/dashboard ./apps/dashboard COPY packages/locales ./packages/locales COPY packages/shared ./packages/shared COPY packages/ui ./packages/ui -RUN pnpm --filter @corvus/dashboard run build +RUN cd apps/dashboard && ../../node_modules/.bin/vite build FROM nginxinc/nginx-unprivileged:1.27-alpine AS runtime diff --git a/clients/web/apps/dashboard/src/App.vue b/clients/web/apps/dashboard/src/App.vue index c70d80901..82f9f1256 100644 --- a/clients/web/apps/dashboard/src/App.vue +++ b/clients/web/apps/dashboard/src/App.vue @@ -42,20 +42,20 @@ const config = useConfig(t);
- - +
@@ -63,8 +63,8 @@ const config = useConfig(t); -

{{ config.statusMessage }}

-

{{ config.errorMessage }}

+

{{ config.statusMessage.value }}

+

{{ config.errorMessage.value }}

From 53e934255239fda4030916cb6aafabf7e8147175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:01:37 +0100 Subject: [PATCH 3/6] feat(docker): add dashboard Docker image build and smoke check functionality --- .github/workflows/README.md | 8 ++++-- .github/workflows/_publish.yml | 52 +++++++++++++++++++++++++++++++++- dev/README.md | 6 ++++ dev/cli.sh | 49 ++++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 4 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 927ed1e55..7b9a2f4b8 100755 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -149,7 +149,8 @@ This directory contains all GitHub Actions workflows for the starter-gradle proj ### `publish-release.yml` - Release Publishing -**Purpose**: Publishes release artifacts to Maven Central, crates.io, npm, Docker Hub, and GHCR, then creates a GitHub release. +**Purpose**: Publishes release artifacts to Maven Central, crates.io, npm, Docker Hub, GHCR, and +dashboard Docker images, then creates a GitHub release. **Triggers**: @@ -219,8 +220,9 @@ Calls the reusable `_publish.yml` workflow with: 3. 👻 Publishes to Maven Central using Gradle 4. 🦀 Publishes Rust crate to crates.io (release only) 5. 📦 Publishes npm package `@dallay/corvus` to npm (release only) -6. 🐳 Builds and publishes multi-arch Docker image to Docker Hub + GHCR (release only) -7. 🚀 Creates GitHub release (if enabled) +6. 🐳 Builds and publishes multi-arch runtime Docker image to Docker Hub + GHCR (release only) +7. 📊 Builds and publishes multi-arch dashboard Docker image to Docker Hub + GHCR (release only) +8. 🚀 Creates GitHub release (if enabled) **Key Points**: diff --git a/.github/workflows/_publish.yml b/.github/workflows/_publish.yml index 3d84989ed..9b613ede4 100755 --- a/.github/workflows/_publish.yml +++ b/.github/workflows/_publish.yml @@ -278,7 +278,6 @@ jobs: type=raw,value=latest - name: 🐳 Build and push Docker image (prebuilt binaries) - continue-on-error: true uses: docker/build-push-action@v6 with: context: . @@ -289,6 +288,57 @@ jobs: tags: ${{ steps.docker-meta.outputs.tags }} labels: ${{ steps.docker-meta.outputs.labels }} + docker-dashboard-image: + if: ${{ inputs.release }} + needs: publish + runs-on: ubuntu-latest + steps: + - name: ✈ Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: 🐳 Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: 🐳 Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 🔐 Login to Docker Hub + continue-on-error: true + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: 🔐 Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 🏷️ Generate dashboard Docker tags and labels + id: dashboard-docker-meta + uses: docker/metadata-action@v5 + with: + images: | + docker.io/${{ secrets.DOCKERHUB_USERNAME }}/corvus-dashboard + ghcr.io/${{ github.repository_owner }}/corvus-dashboard + tags: | + type=semver,pattern={{version}},value=${{ github.ref_name }} + type=semver,pattern={{major}}.{{minor}},value=${{ github.ref_name }} + type=semver,pattern={{major}},value=${{ github.ref_name }} + type=raw,value=latest + + - name: 🐳 Build and push dashboard Docker image + uses: docker/build-push-action@v6 + with: + context: clients/web + file: clients/web/apps/dashboard/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.dashboard-docker-meta.outputs.tags }} + labels: ${{ steps.dashboard-docker-meta.outputs.labels }} + publish-npm-platforms: if: ${{ inputs.release }} needs: build-native-binaries diff --git a/dev/README.md b/dev/README.md index 949fbb041..fe1b3ab72 100755 --- a/dev/README.md +++ b/dev/README.md @@ -86,6 +86,12 @@ If you changed dashboard code and want to rebuild only that image: ./dev/cli.sh build-dashboard ``` +Quick smoke checks (gateway + optional dashboard if running): + +```bash +./dev/cli.sh smoke +``` + ### 5. Persistence & Shared Workspace The local `playground/` directory (in repo root) is mounted as the shared workspace: - **Agent**: `/corvus-data/workspace` diff --git a/dev/cli.sh b/dev/cli.sh index 4967120d7..1c9e3e076 100755 --- a/dev/cli.sh +++ b/dev/cli.sh @@ -21,6 +21,26 @@ YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' # No Color +function wait_http_ok { + local url="$1" + local timeout_secs="$2" + local start_ts + start_ts="$(date +%s)" + + while true; do + if curl -fsS "$url" > /dev/null 2>&1; then + return 0 + fi + + local now_ts + now_ts="$(date +%s)" + if (( now_ts - start_ts >= timeout_secs )); then + return 1 + fi + sleep 1 + done +} + function ensure_config { CONFIG_DIR="$HOST_TARGET_DIR/.corvus" CONFIG_FILE="$CONFIG_DIR/config.toml" @@ -50,6 +70,7 @@ function print_help { echo -e " ${GREEN}logs${NC} View logs" echo -e " ${GREEN}build${NC} Rebuild agent + sandbox images" echo -e " ${GREEN}build-dashboard${NC} Rebuild dashboard image" + echo -e " ${GREEN}smoke${NC} Quick health checks (gateway + optional dashboard)" echo -e " ${GREEN}clean${NC} Stop and wipe workspace data" return 0 @@ -117,6 +138,34 @@ case "$1" in echo -e "${GREEN}✅ Dashboard rebuild complete.${NC}" ;; + smoke) + echo -e "${YELLOW}🧪 Running smoke checks...${NC}" + + if wait_http_ok "http://127.0.0.1:3000/health" 30; then + echo -e "${GREEN}✅ Gateway healthy:${NC} http://127.0.0.1:3000/health" + else + echo -e "${RED}❌ Gateway check failed:${NC} http://127.0.0.1:3000/health" + echo -e " Hint: start with './dev/cli.sh up' or './dev/cli.sh up-dashboard'" + exit 1 + fi + + RUNNING_SERVICES="$(docker compose -f "$COMPOSE_FILE" ps --services --status running || true)" + if echo "$RUNNING_SERVICES" | grep -q "^dashboard-dev$"; then + if wait_http_ok "http://127.0.0.1:4324" 30; then + echo -e "${GREEN}✅ Dashboard reachable:${NC} http://127.0.0.1:4324" + else + echo -e "${RED}❌ Dashboard check failed:${NC} http://127.0.0.1:4324" + echo -e " Hint: check logs with './dev/cli.sh logs'" + exit 1 + fi + else + echo -e "${YELLOW}ℹ️ Dashboard not running (profile not enabled).${NC}" + echo -e " Start it with './dev/cli.sh up-dashboard'" + fi + + echo -e "${GREEN}✅ Smoke checks passed.${NC}" + ;; + clean) echo -e "${RED}⚠️ WARNING: This will delete '$HOST_TARGET_DIR/.corvus' data and Docker volumes.${NC}" read -r -n 1 -p "Are you sure? (y/N) " REPLY From bfbd822c741362ec47efec87e22d0cff0a48d439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:58:01 +0100 Subject: [PATCH 4/6] feat(docker): update Docker actions and improve post-install script error handling --- .github/workflows/_publish.yml | 12 +++---- .github/workflows/fix-renovate.yml | 32 +++++++++++++++++-- .github/workflows/sonarqube-analysis.yml | 3 ++ .../npm/corvus-cli/scripts/postinstall.js | 13 +++++--- clients/web/apps/dashboard/Dockerfile | 6 ++-- .../web/packages/locales/src/parity.spec.ts | 14 ++++---- 6 files changed, 58 insertions(+), 22 deletions(-) diff --git a/.github/workflows/_publish.yml b/.github/workflows/_publish.yml index 9b613ede4..7368a5d49 100755 --- a/.github/workflows/_publish.yml +++ b/.github/workflows/_publish.yml @@ -245,20 +245,20 @@ jobs: chmod +x corvus-linux-x64 corvus-linux-arm64 - name: 🐳 Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: 🐳 Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: 🔐 Login to Docker Hub continue-on-error: true - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: 🔐 Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -318,7 +318,7 @@ jobs: - name: 🏷️ Generate dashboard Docker tags and labels id: dashboard-docker-meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: | docker.io/${{ secrets.DOCKERHUB_USERNAME }}/corvus-dashboard @@ -330,7 +330,7 @@ jobs: type=raw,value=latest - name: 🐳 Build and push dashboard Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: clients/web file: clients/web/apps/dashboard/Dockerfile diff --git a/.github/workflows/fix-renovate.yml b/.github/workflows/fix-renovate.yml index fc8395112..32f9e9330 100755 --- a/.github/workflows/fix-renovate.yml +++ b/.github/workflows/fix-renovate.yml @@ -120,9 +120,37 @@ jobs: - name: 🔒 Write global locks run: ./gradlew writeLocks --no-parallel --no-configuration-cache + - name: 🔐 Re-validate PR head SHA before write actions + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + EXPECTED_SHA: ${{ steps.get-pr-data.outputs.head_sha }} + EXPECTED_REPO: ${{ steps.get-pr-data.outputs.head_repo }} + with: + # language=javascript + script: | + const expectedSha = process.env.EXPECTED_SHA + const expectedRepo = process.env.EXPECTED_REPO + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }) + + const currentRepo = pr.head.repo.full_name + const currentSha = pr.head.sha + + core.info(`Expected repo/SHA: ${expectedRepo} @ ${expectedSha}`) + core.info(`Current repo/SHA: ${currentRepo} @ ${currentSha}`) + + if (currentRepo !== expectedRepo || currentSha !== expectedSha) { + core.setFailed( + `PR head changed after approval/check (expected ${expectedRepo}@${expectedSha}, got ${currentRepo}@${currentSha}). Re-run /fix-lock.` + ) + } + - name: 📝 Get Commit Message run: | - COMMIT_MSG=$(git log --format=%s -n 1) + COMMIT_MSG=$(git log --format=%s -n 1 "${{ steps.get-pr-data.outputs.head_sha }}") echo "COMMIT_MSG=${COMMIT_MSG}" >> $GITHUB_ENV - name: 💾 Commit file changes (only for bots) @@ -132,7 +160,7 @@ jobs: git add -A git diff --cached --quiet || git commit -m "${COMMIT_MSG}" - git push origin ${{ steps.get-pr-data.outputs.head_branch }} + git push origin HEAD:${{ steps.get-pr-data.outputs.head_branch }} - name: 💬 Add reaction to trigger comment uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 diff --git a/.github/workflows/sonarqube-analysis.yml b/.github/workflows/sonarqube-analysis.yml index 6177ed95a..ed6ff7a3b 100644 --- a/.github/workflows/sonarqube-analysis.yml +++ b/.github/workflows/sonarqube-analysis.yml @@ -105,7 +105,10 @@ jobs: -Dsonar.projectKey=${{ steps.sonar_meta.outputs.key }} -Dsonar.projectName=${{ github.event.repository.name }} -Dsonar.sources=. + -Dsonar.tests=. + -Dsonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*.test.tsx,**/*_test.rs,**/tests/** -Dsonar.exclusions=**/.git/**,**/.gradle/**,**/build/**,**/dist/**,**/coverage/**,**/node_modules/**,**/.next/**,**/.turbo/**,**/target/**,**/vendor/**,**/generated/**,**/clients/agent-runtime/target/** + -Dsonar.coverage.exclusions=**/*.spec.ts,**/*.test.ts,**/*.test.tsx,clients/web/apps/dashboard/src/App.vue,clients/web/packages/shared/index.ts,clients/agent-runtime/npm/corvus-cli/scripts/postinstall.js -Dsonar.coverage.jacoco.xmlReportPaths=${{ github.workspace }}/modules/agent-core-kmp/build/reports/kover/report.xml,${{ github.workspace }}/clients/composeApp/build/reports/kover/report.xml -Dsonar.rust.lcov.reportPaths=${{ github.workspace }}/coverage/agent-runtime-coverage.lcov diff --git a/clients/agent-runtime/npm/corvus-cli/scripts/postinstall.js b/clients/agent-runtime/npm/corvus-cli/scripts/postinstall.js index e7fb8eac4..3ab435993 100755 --- a/clients/agent-runtime/npm/corvus-cli/scripts/postinstall.js +++ b/clients/agent-runtime/npm/corvus-cli/scripts/postinstall.js @@ -2,12 +2,15 @@ const { ensureBinary } = require('../lib/install'); -ensureBinary() - .then((binaryPath) => { +async function runPostInstall() { + try { + const binaryPath = await ensureBinary(); console.log(`[corvus] Native binary ready at ${binaryPath}`); - }) - .catch((error) => { + } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`[corvus] Postinstall skipped: ${message}`); console.warn('[corvus] Binary will be downloaded on first run.'); - }); + } +} + +runPostInstall(); diff --git a/clients/web/apps/dashboard/Dockerfile b/clients/web/apps/dashboard/Dockerfile index b2f0ce6a8..3982cc3e0 100644 --- a/clients/web/apps/dashboard/Dockerfile +++ b/clients/web/apps/dashboard/Dockerfile @@ -12,14 +12,16 @@ COPY packages/locales/package.json ./packages/locales/package.json COPY packages/shared/package.json ./packages/shared/package.json COPY packages/ui/package.json ./packages/ui/package.json -RUN pnpm install --frozen-lockfile --node-linker=hoisted +RUN pnpm install --frozen-lockfile --node-linker=hoisted --ignore-scripts COPY apps/dashboard ./apps/dashboard COPY packages/locales ./packages/locales COPY packages/shared ./packages/shared COPY packages/ui ./packages/ui -RUN cd apps/dashboard && ../../node_modules/.bin/vite build +WORKDIR /app/apps/dashboard + +RUN ../../node_modules/.bin/vite build FROM nginxinc/nginx-unprivileged:1.27-alpine AS runtime diff --git a/clients/web/packages/locales/src/parity.spec.ts b/clients/web/packages/locales/src/parity.spec.ts index 6c15c9f26..afebfa2ee 100644 --- a/clients/web/packages/locales/src/parity.spec.ts +++ b/clients/web/packages/locales/src/parity.spec.ts @@ -27,14 +27,14 @@ function extractPlaceholders(text: string): string[] { let start = -1; for (let index = 0; index < text.length; index += 1) { - const charCode = text.charCodeAt(index); + const codePoint = text.codePointAt(index); - if (charCode === 123) { + if (codePoint === 123) { start = index; continue; } - if (charCode === 125 && start >= 0) { + if (codePoint === 125 && start >= 0) { if (index - start > 1) { placeholders.push(text.slice(start, index + 1)); } @@ -42,7 +42,7 @@ function extractPlaceholders(text: string): string[] { } } - return placeholders.sort(); + return placeholders.sort((left, right) => left.localeCompare(right)); } describe("Locale Parity Guard", () => { @@ -50,15 +50,15 @@ describe("Locale Parity Guard", () => { const flattenedEn = flatten(en); it("has identical sets of keys between Spanish and English", () => { - const esKeys = Object.keys(flattenedEs).sort(); - const enKeys = Object.keys(flattenedEn).sort(); + const esKeys = Object.keys(flattenedEs).sort((left, right) => left.localeCompare(right)); + const enKeys = Object.keys(flattenedEn).sort((left, right) => left.localeCompare(right)); expect(esKeys).toEqual(enKeys); }); it("has matching placeholders for all shared keys", () => { for (const key of Object.keys(flattenedEs)) { - if (Object.prototype.hasOwnProperty.call(flattenedEn, key)) { + if (Object.hasOwn(flattenedEn, key)) { const esPlaceholders = extractPlaceholders(flattenedEs[key]); const enPlaceholders = extractPlaceholders(flattenedEn[key]); From 1c7923b119f04bcdf8baa104480f6e992d0ff7a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:14:02 +0100 Subject: [PATCH 5/6] refactor: fix qa code --- .github/workflows/README.md | 29 ++++++++ .github/workflows/_publish.yml | 26 +++++-- .github/workflows/snyk-security.yml | 74 +++++++++++++++++++ clients/agent-runtime/docker-compose.yml | 5 +- clients/agent-runtime/src/agent/classifier.rs | 10 ++- clients/agent-runtime/src/channels/mod.rs | 32 ++++++-- clients/agent-runtime/src/config/schema.rs | 10 +++ clients/agent-runtime/src/gateway/admin.rs | 64 +++++++++++++++- clients/agent-runtime/src/service/mod.rs | 42 ++++++++--- .../agent-runtime/src/tools/git_operations.rs | 52 ++++++++++++- clients/agent-runtime/src/tools/mod.rs | 5 +- clients/web/apps/dashboard/Dockerfile | 2 +- .../web/packages/locales/src/parity.spec.ts | 31 +++++++- dev/cli.sh | 20 +++-- 14 files changed, 355 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/snyk-security.yml diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 7b9a2f4b8..e612321fc 100755 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -10,6 +10,7 @@ This directory contains all GitHub Actions workflows for the starter-gradle proj | **CI/CD** | `pull-request-check-build-logic.yml` | Checks for build-logic changes | Changes to `gradle/build-logic/**` | | **CI/CD** | `deploy-docs.yml` | Deploy documentation to GitHub Pages | Push docs to `main`, Release published | | **Security** | `codeql-analysis.yml` | Security scanning with CodeQL | Push to main/minor, daily schedule | +| **Security** | `snyk-security.yml` | Snyk SAST/SCA/Container/IaC scans | Push/PR to main/minor, manual | | **Publishing** | `publish-release.yml` | Publish release (Maven, Cargo, npm, Docker) | Tag push `v*.*.*` | | **Publishing** | `publish-snapshot.yml` | Publish snapshot versions | Manual, daily schedule | | **Publishing** | `_publish.yml` | Reusable publish workflow | Called by other workflows | @@ -145,6 +146,34 @@ This directory contains all GitHub Actions workflows for the starter-gradle proj --- +### `snyk-security.yml` - Snyk Security Platform Scan + +**Purpose**: Runs Snyk Code, Open Source, Container, and IaC scans and uploads SARIF to GitHub +Code Scanning. + +**Triggers**: + +- Push to `main` or `minor` +- Pull request to `main` or `minor` +- Manual trigger + +**What it does**: + +1. ✈ Checks out repository +2. 🔐 Installs Snyk CLI +3. 🔍 Runs `snyk code test` and uploads SARIF +4. 📚 Runs Open Source scan (`snyk test --all-projects`) +5. 🐳 Builds and scans runtime container image +6. 🧱 Runs IaC scan (`snyk iac test --report`) + +**Key Points**: + +- Requires `SNYK_TOKEN` secret +- `monitor` commands run only on non-PR events +- Findings are currently non-blocking (`continue-on-error: true`) + +--- + ## 📦 Publishing Workflows ### `publish-release.yml` - Release Publishing diff --git a/.github/workflows/_publish.yml b/.github/workflows/_publish.yml index 7368a5d49..7a0eae209 100755 --- a/.github/workflows/_publish.yml +++ b/.github/workflows/_publish.yml @@ -297,32 +297,44 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: 🐳 Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: 🐳 Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: 🔐 Login to Docker Hub + if: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }} continue-on-error: true - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: 🔐 Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: 🧮 Compute dashboard image targets + id: dashboard-targets + shell: bash + run: | + { + echo 'images<> "$GITHUB_OUTPUT" + - name: 🏷️ Generate dashboard Docker tags and labels id: dashboard-docker-meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: - images: | - docker.io/${{ secrets.DOCKERHUB_USERNAME }}/corvus-dashboard - ghcr.io/${{ github.repository_owner }}/corvus-dashboard + images: ${{ steps.dashboard-targets.outputs.images }} tags: | type=semver,pattern={{version}},value=${{ github.ref_name }} type=semver,pattern={{major}}.{{minor}},value=${{ github.ref_name }} diff --git a/.github/workflows/snyk-security.yml b/.github/workflows/snyk-security.yml new file mode 100644 index 000000000..b6ff29c1c --- /dev/null +++ b/.github/workflows/snyk-security.yml @@ -0,0 +1,74 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +# This workflow uses third-party actions that are governed by their own terms. +name: Snyk Security + +on: + push: + branches: + - main + - minor + pull_request: + branches: + - main + - minor + workflow_dispatch: + +permissions: + contents: read + +jobs: + snyk: + if: ${{ secrets.SNYK_TOKEN != '' }} + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + + steps: + - name: ✈ Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: 📦 Setup Node + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + with: + node-version: "24" + + - name: 🔐 Setup Snyk CLI + uses: snyk/actions/setup@806182742461562b67788a64410098c9d9b96adb + + - name: 🔍 Snyk Code test (SARIF) + continue-on-error: true + run: snyk code test --sarif > snyk-code.sarif + + - name: 📤 Upload Snyk Code SARIF to GitHub + uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 + with: + sarif_file: snyk-code.sarif + + - name: 📚 Snyk Open Source test + continue-on-error: true + run: snyk test --all-projects + + - name: 📡 Snyk Open Source monitor + if: github.event_name != 'pull_request' + continue-on-error: true + run: snyk monitor --all-projects + + - name: 🏗️ Build runtime Docker image for scan + run: docker build -t corvus-runtime-snyk:scan -f clients/agent-runtime/Dockerfile . + + - name: 🐳 Snyk Container test + continue-on-error: true + run: snyk container test corvus-runtime-snyk:scan --file=clients/agent-runtime/Dockerfile + + - name: 🐳 Snyk Container monitor + if: github.event_name != 'pull_request' + continue-on-error: true + run: snyk container monitor corvus-runtime-snyk:scan --file=clients/agent-runtime/Dockerfile + + - name: 🧱 Snyk IaC test + continue-on-error: true + run: snyk iac test --report diff --git a/clients/agent-runtime/docker-compose.yml b/clients/agent-runtime/docker-compose.yml index caa643c56..e80b14e32 100755 --- a/clients/agent-runtime/docker-compose.yml +++ b/clients/agent-runtime/docker-compose.yml @@ -64,10 +64,7 @@ services: # Optional local web dashboard (local-first) dashboard: - build: - context: ../web - dockerfile: apps/dashboard/Dockerfile - image: corvus-dashboard:local + image: ghcr.io/dallay/corvus-dashboard:latest container_name: corvus-dashboard restart: unless-stopped depends_on: diff --git a/clients/agent-runtime/src/agent/classifier.rs b/clients/agent-runtime/src/agent/classifier.rs index 8f09fe839..95f236a21 100755 --- a/clients/agent-runtime/src/agent/classifier.rs +++ b/clients/agent-runtime/src/agent/classifier.rs @@ -28,13 +28,21 @@ fn has_keyword_or_pattern_match( ) -> bool { let keyword_hit = keywords .iter() - .any(|keyword| lower_message.contains(&keyword.to_lowercase())); + .any(|keyword| keyword_matches(lower_message, keyword)); let pattern_hit = patterns .iter() .any(|pattern| message.contains(pattern.as_str())); keyword_hit || pattern_hit } +fn keyword_matches(lower_message: &str, keyword: &str) -> bool { + if keyword.bytes().all(|byte| !byte.is_ascii_uppercase()) { + return lower_message.contains(keyword); + } + + lower_message.contains(&keyword.to_ascii_lowercase()) +} + /// Classify a user message against the configured rules and return the /// matching hint string, if any. /// diff --git a/clients/agent-runtime/src/channels/mod.rs b/clients/agent-runtime/src/channels/mod.rs index d168b1e8d..b11928393 100755 --- a/clients/agent-runtime/src/channels/mod.rs +++ b/clients/agent-runtime/src/channels/mod.rs @@ -1202,7 +1202,7 @@ fn maybe_restart_launchd_daemon_service() -> Result { let plist = home .join("Library") .join("LaunchAgents") - .join("com.corvus.daemon.plist"); + .join(crate::service::launchd_plist_file_name()); if !plist.exists() { return Ok(false); } @@ -1211,16 +1211,21 @@ fn maybe_restart_launchd_daemon_service() -> Result { .arg("list") .output() .context("Failed to query launchctl list")?; + if !list_output.status.success() { + let stderr = String::from_utf8_lossy(&list_output.stderr); + anyhow::bail!("launchctl list failed: {}", stderr.trim()); + } + let listed = String::from_utf8_lossy(&list_output.stdout); - if !listed.contains("com.corvus.daemon") { + if !listed.contains(crate::service::launchd_service_label()) { return Ok(false); } let _ = Command::new("launchctl") - .args(["stop", "com.corvus.daemon"]) + .args(["stop", crate::service::launchd_service_label()]) .output(); let start_output = Command::new("launchctl") - .args(["start", "com.corvus.daemon"]) + .args(["start", crate::service::launchd_service_label()]) .output() .context("Failed to start launchd daemon service")?; if !start_output.status.success() { @@ -1239,22 +1244,35 @@ fn maybe_restart_systemd_daemon_service() -> Result { .join(".config") .join("systemd") .join("user") - .join("corvus.service"); + .join(crate::service::systemd_user_unit_name()); if !unit_path.exists() { return Ok(false); } let active_output = Command::new("systemctl") - .args(["--user", "is-active", "corvus.service"]) + .args([ + "--user", + "is-active", + crate::service::systemd_user_unit_name(), + ]) .output() .context("Failed to query systemd service state")?; + if !active_output.status.success() { + let stderr = String::from_utf8_lossy(&active_output.stderr); + anyhow::bail!("systemctl --user is-active failed: {}", stderr.trim()); + } + let state = String::from_utf8_lossy(&active_output.stdout); if !state.trim().eq_ignore_ascii_case("active") { return Ok(false); } let restart_output = Command::new("systemctl") - .args(["--user", "restart", "corvus.service"]) + .args([ + "--user", + "restart", + crate::service::systemd_user_unit_name(), + ]) .output() .context("Failed to restart systemd daemon service")?; if !restart_output.status.success() { diff --git a/clients/agent-runtime/src/config/schema.rs b/clients/agent-runtime/src/config/schema.rs index 3e2e18dea..98a3ecbd8 100644 --- a/clients/agent-runtime/src/config/schema.rs +++ b/clients/agent-runtime/src/config/schema.rs @@ -2422,6 +2422,14 @@ fn is_valid_mcp_identifier(value: &str) -> bool { } impl Config { + fn normalize_query_classification_keywords(&mut self) { + for rule in &mut self.query_classification.rules { + for keyword in &mut rule.keywords { + *keyword = keyword.to_ascii_lowercase(); + } + } + } + pub fn load_or_init() -> Result { let (default_corvus_dir, default_workspace_dir) = default_config_and_workspace_dirs()?; @@ -2667,6 +2675,8 @@ impl Config { } } } + + self.normalize_query_classification_keywords(); } pub fn validate_for_runtime(&self) -> Result<()> { diff --git a/clients/agent-runtime/src/gateway/admin.rs b/clients/agent-runtime/src/gateway/admin.rs index fb8ce4e7d..9eed259d8 100644 --- a/clients/agent-runtime/src/gateway/admin.rs +++ b/clients/agent-runtime/src/gateway/admin.rs @@ -1498,10 +1498,21 @@ fn apply_webhook_patch(cfg: &mut Config, patch: &AdminWebhookPatch) -> Result<() return Ok(()); } - if patch.port.is_none() && patch.secret.is_none() { + let updates_secret = matches!( + patch.secret, + Some(AdminSecretUpdate::Clear | AdminSecretUpdate::Replace { .. }) + ); + let updates_settings = patch.port.is_some() || updates_secret; + if !updates_settings { return Ok(()); } + if cfg.channels_config.webhook.is_none() && patch.enabled != Some(true) { + return Err(bad_request( + "channels.webhook is disabled; set channels.webhook.enabled=true before updating port or secret", + )); + } + ensure_webhook_config(cfg); if let Some(webhook) = cfg.channels_config.webhook.as_mut() { @@ -1896,4 +1907,55 @@ mod tests { ); } } + + #[test] + fn webhook_patch_rejects_secret_or_port_updates_when_disabled_without_enable_flag() { + let mut cfg = Config::default(); + cfg.channels_config.webhook = None; + + let err = apply_webhook_patch( + &mut cfg, + &AdminWebhookPatch { + enabled: None, + port: Some(9000), + secret: None, + }, + ) + .expect_err("port update must fail when webhook is disabled"); + assert_eq!(err.0, StatusCode::BAD_REQUEST); + assert!(cfg.channels_config.webhook.is_none()); + + let err = apply_webhook_patch( + &mut cfg, + &AdminWebhookPatch { + enabled: None, + port: None, + secret: Some(AdminSecretUpdate::Replace { + value: "new-secret".to_string(), + }), + }, + ) + .expect_err("secret update must fail when webhook is disabled"); + assert_eq!(err.0, StatusCode::BAD_REQUEST); + assert!(cfg.channels_config.webhook.is_none()); + } + + #[test] + fn restart_required_updates_preserves_webhook_semantics_for_disabled_patch_updates() { + let cfg = Config::default(); + let patch = AdminConfigUpdateRequest { + channels: Some(AdminChannelsPatch { + cli: None, + webhook: Some(AdminWebhookPatch { + enabled: None, + port: Some(9000), + secret: None, + }), + }), + ..empty_patch() + }; + + let fields = restart_required_updates(&cfg, &patch); + assert!(fields.contains(&"channels.webhook.port")); + } } diff --git a/clients/agent-runtime/src/service/mod.rs b/clients/agent-runtime/src/service/mod.rs index 54fd47c10..88dfb470c 100755 --- a/clients/agent-runtime/src/service/mod.rs +++ b/clients/agent-runtime/src/service/mod.rs @@ -6,8 +6,21 @@ use std::path::PathBuf; use std::process::Command; const SERVICE_LABEL: &str = "com.corvus.daemon"; +const LINUX_USER_UNIT_NAME: &str = "corvus.service"; const WINDOWS_TASK_NAME: &str = "Corvus Daemon"; +pub(crate) fn launchd_service_label() -> &'static str { + SERVICE_LABEL +} + +pub(crate) fn launchd_plist_file_name() -> String { + format!("{SERVICE_LABEL}.plist") +} + +pub(crate) fn systemd_user_unit_name() -> &'static str { + LINUX_USER_UNIT_NAME +} + fn windows_task_name() -> &'static str { WINDOWS_TASK_NAME } @@ -58,8 +71,12 @@ fn install(config: &Config, linger: crate::ServiceLingerMode) -> Result<()> { fn restart(config: &Config) -> Result<()> { if cfg!(target_os = "linux") - && run_checked(Command::new("systemctl").args(["--user", "restart", "corvus.service"])) - .is_ok() + && run_checked(Command::new("systemctl").args([ + "--user", + "restart", + systemd_user_unit_name(), + ])) + .is_ok() { println!("✅ Service restarted"); return Ok(()); @@ -78,7 +95,7 @@ fn start(config: &Config) -> Result<()> { Ok(()) } else if cfg!(target_os = "linux") { run_checked(Command::new("systemctl").args(["--user", "daemon-reload"]))?; - run_checked(Command::new("systemctl").args(["--user", "start", "corvus.service"]))?; + run_checked(Command::new("systemctl").args(["--user", "start", systemd_user_unit_name()]))?; println!("✅ Service started"); Ok(()) } else if cfg!(target_os = "windows") { @@ -105,7 +122,11 @@ fn stop(config: &Config) -> Result<()> { println!("✅ Service stopped"); Ok(()) } else if cfg!(target_os = "linux") { - let _ = run_checked(Command::new("systemctl").args(["--user", "stop", "corvus.service"])); + let _ = run_checked(Command::new("systemctl").args([ + "--user", + "stop", + systemd_user_unit_name(), + ])); println!("✅ Service stopped"); Ok(()) } else if cfg!(target_os = "windows") { @@ -137,9 +158,12 @@ fn status(config: &Config) -> Result<()> { } if cfg!(target_os = "linux") { - let out = - run_capture(Command::new("systemctl").args(["--user", "is-active", "corvus.service"])) - .unwrap_or_else(|_| "unknown".into()); + let out = run_capture(Command::new("systemctl").args([ + "--user", + "is-active", + systemd_user_unit_name(), + ])) + .unwrap_or_else(|_| "unknown".into()); println!("Service state: {}", out.trim()); match linux_linger_state() { Ok(Some(enabled)) => println!( @@ -432,7 +456,7 @@ fn macos_service_file() -> Result { Ok(home .join("Library") .join("LaunchAgents") - .join(format!("{SERVICE_LABEL}.plist"))) + .join(launchd_plist_file_name())) } fn linux_service_file(config: &Config) -> Result { @@ -444,7 +468,7 @@ fn linux_service_file(config: &Config) -> Result { .join(".config") .join("systemd") .join("user") - .join("corvus.service")) + .join(systemd_user_unit_name())) } fn run_checked(command: &mut Command) -> Result<()> { diff --git a/clients/agent-runtime/src/tools/git_operations.rs b/clients/agent-runtime/src/tools/git_operations.rs index fea918d50..d5b1c9255 100755 --- a/clients/agent-runtime/src/tools/git_operations.rs +++ b/clients/agent-runtime/src/tools/git_operations.rs @@ -131,15 +131,18 @@ impl GitOperationsTool { staged: &mut Vec, unstaged: &mut Vec, ) { - let mut parts = rest.splitn(3, ' '); - let (Some(staging), Some(path)) = (parts.next(), parts.next()) else { + let parts: Vec<&str> = rest.split_whitespace().collect(); + if parts.len() < 8 { return; - }; + } + let staging = parts[0]; if staging.is_empty() { return; } + let path = parts[7]; + let staged_status = staging.chars().next().unwrap_or(' '); Self::push_status_if_changed(staged, staged_status, path); @@ -877,4 +880,47 @@ mod tests { assert_eq!(truncated.chars().count(), 2000); } + + #[test] + fn parse_ordinary_changed_entry_uses_porcelain_v2_path_field() { + let mut staged = Vec::new(); + let mut unstaged = Vec::new(); + + GitOperationsTool::parse_ordinary_changed_entry( + "M. N... 100644 100644 100644 1234567 89abcde src/main.rs", + &mut staged, + &mut unstaged, + ); + + assert_eq!(staged.len(), 1); + assert_eq!(staged[0]["path"], "src/main.rs"); + assert_eq!(staged[0]["status"], "M"); + + assert_eq!(unstaged.len(), 0); + } + + #[test] + fn parse_porcelain_status_line_extracts_staged_and_unstaged_paths() { + let mut staged = Vec::new(); + let mut unstaged = Vec::new(); + let mut untracked = Vec::new(); + let mut branch = String::new(); + + GitOperationsTool::parse_porcelain_status_line( + "1 MM N... 100644 100644 100644 aaaaaaa bbbbbbb src/lib.rs", + &mut staged, + &mut unstaged, + &mut untracked, + &mut branch, + ); + + assert_eq!(staged.len(), 1); + assert_eq!(staged[0]["path"], "src/lib.rs"); + assert_eq!(staged[0]["status"], "M"); + assert_eq!(unstaged.len(), 1); + assert_eq!(unstaged[0]["path"], "src/lib.rs"); + assert_eq!(unstaged[0]["status"], "M"); + assert!(untracked.is_empty()); + assert!(branch.is_empty()); + } } diff --git a/clients/agent-runtime/src/tools/mod.rs b/clients/agent-runtime/src/tools/mod.rs index 6a65cb3be..2d2f83103 100755 --- a/clients/agent-runtime/src/tools/mod.rs +++ b/clients/agent-runtime/src/tools/mod.rs @@ -200,10 +200,7 @@ fn add_delegate_tool( return; } - let delegate_agents: HashMap = agents - .iter() - .map(|(name, cfg)| (name.clone(), cfg.clone())) - .collect(); + let delegate_agents: HashMap = agents.clone(); let delegate_fallback_credential = fallback_api_key.and_then(|value| { let trimmed_value = value.trim(); (!trimmed_value.is_empty()).then(|| trimmed_value.to_owned()) diff --git a/clients/web/apps/dashboard/Dockerfile b/clients/web/apps/dashboard/Dockerfile index 3982cc3e0..f31fb835b 100644 --- a/clients/web/apps/dashboard/Dockerfile +++ b/clients/web/apps/dashboard/Dockerfile @@ -21,7 +21,7 @@ COPY packages/ui ./packages/ui WORKDIR /app/apps/dashboard -RUN ../../node_modules/.bin/vite build +RUN pnpm exec vite build FROM nginxinc/nginx-unprivileged:1.27-alpine AS runtime diff --git a/clients/web/packages/locales/src/parity.spec.ts b/clients/web/packages/locales/src/parity.spec.ts index afebfa2ee..17c03fa1b 100644 --- a/clients/web/packages/locales/src/parity.spec.ts +++ b/clients/web/packages/locales/src/parity.spec.ts @@ -25,20 +25,29 @@ function flatten( function extractPlaceholders(text: string): string[] { const placeholders: string[] = []; let start = -1; + let depth = 0; for (let index = 0; index < text.length; index += 1) { const codePoint = text.codePointAt(index); if (codePoint === 123) { - start = index; + if (start < 0) { + start = index; + } + depth += 1; continue; } if (codePoint === 125 && start >= 0) { - if (index - start > 1) { - placeholders.push(text.slice(start, index + 1)); + depth -= 1; + + if (depth <= 0) { + if (index - start > 1) { + placeholders.push(text.slice(start, index + 1)); + } + start = -1; + depth = 0; } - start = -1; } } @@ -66,4 +75,18 @@ describe("Locale Parity Guard", () => { } } }); + + it("preserves double-brace placeholders as distinct tokens", () => { + const placeholders = extractPlaceholders("Hi {{name}} and {name}"); + + expect(placeholders).toEqual(["{name}", "{{name}}"].sort((a, b) => a.localeCompare(b))); + }); + + it("preserves nested brace placeholder shapes", () => { + const placeholders = extractPlaceholders("Value {outer{inner}} and {{user}}"); + + expect(placeholders).toEqual( + ["{outer{inner}}", "{{user}}"].sort((a, b) => a.localeCompare(b)), + ); + }); }); diff --git a/dev/cli.sh b/dev/cli.sh index 1c9e3e076..4a72ca86e 100755 --- a/dev/cli.sh +++ b/dev/cli.sh @@ -28,15 +28,23 @@ function wait_http_ok { start_ts="$(date +%s)" while true; do - if curl -fsS "$url" > /dev/null 2>&1; then - return 0 - fi - - local now_ts + local now_ts elapsed remaining now_ts="$(date +%s)" - if (( now_ts - start_ts >= timeout_secs )); then + elapsed=$(( now_ts - start_ts )) + remaining=$(( timeout_secs - elapsed )) + + if (( remaining <= 0 )); then return 1 fi + + if (( remaining < 1 )); then + remaining=1 + fi + + if curl -fsS --connect-timeout "$remaining" --max-time "$remaining" "$url" > /dev/null 2>&1; then + return 0 + fi + sleep 1 done } From 8b4ee1521b971ec6d2e0c96dd311534c5a511002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:24:41 +0100 Subject: [PATCH 6/6] chore(deps): refresh web lockfile after CI checks --- clients/web/pnpm-lock.yaml | 129 +++++++++++++++++++++++++++++++++---- 1 file changed, 118 insertions(+), 11 deletions(-) diff --git a/clients/web/pnpm-lock.yaml b/clients/web/pnpm-lock.yaml index c71804a97..300f2e083 100644 --- a/clients/web/pnpm-lock.yaml +++ b/clients/web/pnpm-lock.yaml @@ -247,13 +247,13 @@ importers: dependencies: '@astrojs/starlight': specifier: 'catalog:' - version: 0.37.6(astro@5.18.0(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2)) + version: 0.37.6(astro@5.17.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2)) '@corvus/shared': specifier: workspace:* version: link:../../packages/shared astro: - specifier: 'catalog:' - version: 5.18.0(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2) + specifier: 5.17.3 + version: 5.17.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2) sharp: specifier: 'catalog:' version: 0.34.5 @@ -1623,6 +1623,11 @@ packages: astro-vtbot@2.1.11: resolution: {integrity: sha512-M0b2y2Kh4BcHryfY2dP+xrZOH2zYHEYM+x+1NFvCoK+g8iGRE/va6DxPN/zJ07IwWYjGl9PizLA0ZLJJl0a2sg==} + astro@5.17.3: + resolution: {integrity: sha512-69dcfPe8LsHzklwj+hl+vunWUbpMB6pmg35mACjetxbJeUNNys90JaBM8ZiwsPK689SAj/4Zqb1ayaANls9/MA==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + astro@5.18.0: resolution: {integrity: sha512-CHiohwJIS4L0G6/IzE1Fx3dgWqXBCXus/od0eGUfxrZJD2um2pE7ehclMmgL/fXqbU7NfE1Ze2pq34h2QaA6iQ==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} @@ -3452,12 +3457,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/mdx@4.3.13(astro@5.18.0(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2))': + '@astrojs/mdx@4.3.13(astro@5.17.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2))': dependencies: '@astrojs/markdown-remark': 6.3.10 '@mdx-js/mdx': 3.1.1 acorn: 8.16.0 - astro: 5.18.0(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2) + astro: 5.17.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2) es-module-lexer: 1.7.0 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -3486,17 +3491,17 @@ snapshots: stream-replace-string: 2.0.0 zod: 3.25.76 - '@astrojs/starlight@0.37.6(astro@5.18.0(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2))': + '@astrojs/starlight@0.37.6(astro@5.17.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2))': dependencies: '@astrojs/markdown-remark': 6.3.10 - '@astrojs/mdx': 4.3.13(astro@5.18.0(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2)) + '@astrojs/mdx': 4.3.13(astro@5.17.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2)) '@astrojs/sitemap': 3.7.0 '@pagefind/default-ui': 1.4.0 '@types/hast': 3.0.4 '@types/js-yaml': 4.0.9 '@types/mdast': 4.0.4 - astro: 5.18.0(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2) - astro-expressive-code: 0.41.7(astro@5.18.0(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2)) + astro: 5.17.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2) + astro-expressive-code: 0.41.7(astro@5.17.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2)) bcp-47: 2.1.0 hast-util-from-html: 2.0.3 hast-util-select: 6.0.4 @@ -4546,9 +4551,9 @@ snapshots: astring@1.9.0: {} - astro-expressive-code@0.41.7(astro@5.18.0(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2)): + astro-expressive-code@0.41.7(astro@5.17.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2)): dependencies: - astro: 5.18.0(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2) + astro: 5.17.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2) rehype-expressive-code: 0.41.7 astro-vtbot@2.1.11: @@ -4559,6 +4564,108 @@ snapshots: '@vtbag/turn-signal': 1.3.1 '@vtbag/utensil-drawer': 1.2.16 + astro@5.17.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2): + dependencies: + '@astrojs/compiler': 2.13.1 + '@astrojs/internal-helpers': 0.7.5 + '@astrojs/markdown-remark': 6.3.10 + '@astrojs/telemetry': 3.3.0 + '@capsizecss/unpack': 4.0.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + acorn: 8.16.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 1.1.1 + cssesc: 3.0.0 + debug: 4.4.3 + deterministic-object-hash: 2.0.2 + devalue: 5.6.3 + diff: 8.0.3 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.27.3 + estree-walker: 3.0.3 + flattie: 1.1.1 + fontace: 0.4.1 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + import-meta-resolve: 4.2.0 + js-yaml: 4.1.1 + magic-string: 0.30.21 + magicast: 0.5.2 + mrmime: 2.0.1 + neotraverse: 0.6.18 + p-limit: 6.2.0 + p-queue: 8.1.1 + package-manager-detector: 1.6.0 + piccolore: 0.1.3 + picomatch: 4.0.3 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.7.4 + shiki: 3.23.0 + smol-toml: 1.6.0 + svgo: 4.0.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tsconfck: 3.1.6(typescript@5.9.3) + ultrahtml: 1.6.0 + unifont: 0.7.4 + unist-util-visit: 5.1.0 + unstorage: 1.17.4 + vfile: 6.0.3 + vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + vitefu: 1.1.2(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) + xxhash-wasm: 1.1.0 + yargs-parser: 21.1.1 + yocto-spinner: 0.2.3 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - yaml + astro@5.18.0(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.59.0)(typescript@5.9.3)(yaml@2.8.2): dependencies: '@astrojs/compiler': 2.13.1