From e3cde1dc652d9924bb9f3201587afc77fa7afbda Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Sun, 22 Feb 2026 16:14:16 -0500 Subject: [PATCH 1/3] fix(slack): Slack channel fixes, DM filtering, emoji sanitization, and TLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes several Slack adapter issues and restores the build after #117. Build fixes: - Restore compile after security middleware changes (Axum State extractor pattern in api_auth_middleware, url::Url → reqwest::Url in browser tool) (this is from unmerged pr #125) Slack DM filtering: - DMs now bypass workspace/channel filters when sender is in dm_allowed_users - dm_allowed_users is merged from both SlackConfig and per-binding configs - Added debug logging for DM permission decisions Emoji reactions: - Sanitize Slack emoji reactions to use shortcodes via the `emojis` crate - Handle edge case where emoji has no shortcode (falls back to name) - Strip colons, normalize whitespace and casing TLS connectivity: - Add tokio-tungstenite with rustls-tls-native-roots feature to fix wss:// connections that broke after the tungstenite 0.28 TLS feature restructure Logging: - Downgrade per-message Slack log from info to debug, matching Discord, Telegram, and Twitch adapters which only use info for lifecycle events Style: - Rename abbreviated `uid` to `sender_id` per style guide - Remove section-divider comments and extra blank lines in imports Tests: - 9 unit tests for sanitize_reaction_name (unicode, shortcodes, fallbacks) - 7 unit tests for SlackPermissions::from_config (merging, dedup, filtering) --- Cargo.lock | 33 ++++++++- Cargo.toml | 4 ++ src/api/server.rs | 14 +++- src/config.rs | 110 +++++++++++++++++++++++++++++- src/messaging/slack.rs | 151 +++++++++++++++++++++++++++++++++++------ src/tools/browser.rs | 9 ++- 6 files changed, 290 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bb572829c..80a60fa3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1216,7 +1216,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" dependencies = [ "chrono", - "phf", + "phf 0.12.1", ] [[package]] @@ -2665,6 +2665,15 @@ dependencies = [ "serde", ] +[[package]] +name = "emojis" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c1c1870b766fc398e5f0526498d09c94b6de15be5fd769a28bbc804fb1b05d" +dependencies = [ + "phf 0.13.1", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -6156,7 +6165,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" dependencies = [ - "phf_shared", + "phf_shared 0.12.1", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", ] [[package]] @@ -6168,6 +6186,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -8084,6 +8111,7 @@ dependencies = [ "daemonize", "dialoguer", "dirs", + "emojis", "fastembed", "futures", "ignore", @@ -8124,6 +8152,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-test", + "tokio-tungstenite 0.28.0", "toml 0.8.23", "toml_edit 0.22.27", "tower-http", diff --git a/Cargo.toml b/Cargo.toml index f5bb2692b..50abc8cc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,9 +91,13 @@ async-trait = "0.1" # Slack slack-morphism = { version = "2.17", features = ["hyper"] } +emojis = "0.8" # TLS (shared crypto backend for slack-morphism, reqwest, teloxide) rustls = { version = "0.23", default-features = false, features = ["ring"] } +# slack-morphism enables tokio-tungstenite/rustls-native-certs, but tokio-tungstenite 0.28 +# restructured features — rustls-tls-native-roots is now needed to activate actual TLS. +tokio-tungstenite = { version = "0.28", features = ["rustls-tls-native-roots"] } # Telegram teloxide = { version = "0.17", default-features = false, features = ["rustls"] } diff --git a/src/api/server.rs b/src/api/server.rs index 39dcc9d64..ca4c59972 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -7,8 +7,8 @@ use super::{ }; use axum::Json; -use axum::extract::Request; use axum::Router; +use axum::extract::{Request, State}; use axum::http::{StatusCode, Uri, header}; use axum::middleware::{self, Next}; use axum::response::{Html, IntoResponse, Response}; @@ -177,7 +177,11 @@ pub async fn start_http_server( Ok(handle) } -async fn api_auth_middleware(state: Arc, request: Request, next: Next) -> Response { +async fn api_auth_middleware( + State(state): State>, + request: Request, + next: Next, +) -> Response { let Some(expected_token) = state.auth_token.as_deref() else { return next.run(request).await; }; @@ -197,7 +201,11 @@ async fn api_auth_middleware(state: Arc, request: Request, next: Next) if is_authorized { next.run(request).await } else { - (StatusCode::UNAUTHORIZED, Json(json!({"error": "unauthorized"}))).into_response() + ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "unauthorized"})), + ) + .into_response() } } diff --git a/src/config.rs b/src/config.rs index 6e1c1586a..5459b3b18 100644 --- a/src/config.rs +++ b/src/config.rs @@ -877,7 +877,15 @@ impl SlackPermissions { filter }; - let dm_allowed_users = slack.dm_allowed_users.clone(); + let mut dm_allowed_users = slack.dm_allowed_users.clone(); + + for binding in &slack_bindings { + for id in &binding.dm_allowed_users { + if !dm_allowed_users.contains(id) { + dm_allowed_users.push(id.clone()); + } + } + } Self { workspace_filter, @@ -3985,4 +3993,104 @@ bind = "127.0.0.1" assert_eq!(config.api.bind, "[::]"); } + + + /// Helper to build a minimal `SlackConfig` for permission tests. + fn slack_config_with_dm_users(dm_allowed_users: Vec) -> SlackConfig { + SlackConfig { + enabled: true, + bot_token: "xoxb-test".into(), + app_token: "xapp-test".into(), + dm_allowed_users, + commands: vec![], + } + } + + /// Helper to build a Slack binding with optional dm_allowed_users. + fn slack_binding(workspace_id: Option<&str>, dm_allowed_users: Vec) -> Binding { + Binding { + agent_id: "test-agent".into(), + channel: "slack".into(), + guild_id: None, + workspace_id: workspace_id.map(String::from), + chat_id: None, + channel_ids: vec![], + require_mention: false, + dm_allowed_users, + } + } + + #[test] + fn slack_permissions_merges_dm_users_from_config_and_bindings() { + let config = slack_config_with_dm_users(vec!["U001".into(), "U002".into()]); + let bindings = vec![slack_binding( + Some("T1"), + vec!["U003".into(), "U004".into()], + )]; + let perms = SlackPermissions::from_config(&config, &bindings); + assert_eq!(perms.dm_allowed_users, vec!["U001", "U002", "U003", "U004"]); + } + + #[test] + fn slack_permissions_deduplicates_dm_users() { + let config = slack_config_with_dm_users(vec!["U001".into(), "U002".into()]); + let bindings = vec![slack_binding( + Some("T1"), + vec!["U002".into(), "U003".into()], + )]; + let perms = SlackPermissions::from_config(&config, &bindings); + // U002 appears in both config and binding — should appear only once + assert_eq!(perms.dm_allowed_users, vec!["U001", "U002", "U003"]); + } + + #[test] + fn slack_permissions_empty_dm_users_stays_empty() { + let config = slack_config_with_dm_users(vec![]); + let bindings = vec![slack_binding(Some("T1"), vec![])]; + let perms = SlackPermissions::from_config(&config, &bindings); + assert!(perms.dm_allowed_users.is_empty()); + } + + #[test] + fn slack_permissions_merges_dm_users_from_multiple_bindings() { + let config = slack_config_with_dm_users(vec!["U001".into()]); + let bindings = vec![ + slack_binding(Some("T1"), vec!["U002".into()]), + slack_binding(Some("T2"), vec!["U003".into()]), + ]; + let perms = SlackPermissions::from_config(&config, &bindings); + assert_eq!(perms.dm_allowed_users, vec!["U001", "U002", "U003"]); + } + + #[test] + fn slack_permissions_ignores_non_slack_bindings() { + let config = slack_config_with_dm_users(vec!["U001".into()]); + let mut discord_binding = slack_binding(Some("T1"), vec!["U099".into()]); + discord_binding.channel = "discord".into(); + let perms = SlackPermissions::from_config(&config, &[discord_binding]); + // U099 should not appear — that binding is for discord, not slack + assert_eq!(perms.dm_allowed_users, vec!["U001"]); + } + + #[test] + fn slack_permissions_workspace_filter_from_bindings() { + let config = slack_config_with_dm_users(vec![]); + let bindings = vec![ + slack_binding(Some("T1"), vec![]), + slack_binding(Some("T2"), vec![]), + ]; + let perms = SlackPermissions::from_config(&config, &bindings); + assert_eq!( + perms.workspace_filter, + Some(vec!["T1".to_string(), "T2".to_string()]) + ); + } + + #[test] + fn slack_permissions_no_workspace_filter_when_none_specified() { + let config = slack_config_with_dm_users(vec![]); + let bindings = vec![slack_binding(None, vec![])]; + let perms = SlackPermissions::from_config(&config, &bindings); + assert!(perms.workspace_filter.is_none()); + } } diff --git a/src/messaging/slack.rs b/src/messaging/slack.rs index 19e896665..90017b2d6 100644 --- a/src/messaging/slack.rs +++ b/src/messaging/slack.rs @@ -117,12 +117,19 @@ async fn handle_push_event( ) -> UserCallbackResult<()> { match event.event { SlackEventCallbackBody::Message(msg) => { + let channel = msg.origin.channel.as_ref().map(|c| c.0.as_str()).unwrap_or("none"); + let sender = msg.sender.user.as_ref().map(|u| u.0.as_str()).unwrap_or("none"); + let subtype = msg.subtype.as_ref().map(|s| format!("{:?}", s)); + tracing::debug!(channel, sender, ?subtype, "slack push event: message"); handle_message_event(msg, &event.team_id, client, states).await } SlackEventCallbackBody::AppMention(mention) => { handle_app_mention_event(mention, &event.team_id, client, states).await } - _ => Ok(()), + _ => { + tracing::debug!(event_type = ?std::mem::discriminant(&event.event), "slack push event: unhandled"); + Ok(()) + } } } @@ -162,32 +169,38 @@ async fn handle_message_event( let ts = msg_event.origin.ts.0.clone(); let perms = adapter_state.permissions.load(); + let is_dm = channel_id.starts_with('D'); - // DM filter - if channel_id.starts_with('D') { + // DM filter — allowed DMs skip workspace/channel filters entirely + if is_dm { if perms.dm_allowed_users.is_empty() { + tracing::debug!(channel_id, "DM dropped: dm_allowed_users is empty"); return Ok(()); } - if let Some(ref uid) = user_id - && !perms.dm_allowed_users.contains(uid) + if let Some(ref sender_id) = user_id + && !perms.dm_allowed_users.contains(sender_id) { + tracing::debug!(channel_id, user_id = sender_id.as_str(), "DM dropped: user not in dm_allowed_users"); return Ok(()); } + tracing::info!(channel_id, ?user_id, "DM permitted, bypassing channel filter"); } - // Workspace filter - if let Some(ref filter) = perms.workspace_filter - && !filter.contains(&team_id_str) - { - return Ok(()); - } + if !is_dm { + // Workspace filter + if let Some(ref filter) = perms.workspace_filter + && !filter.contains(&team_id_str) + { + return Ok(()); + } - // Channel filter - if let Some(allowed) = perms.channel_filter.get(&team_id_str) - && !allowed.is_empty() - && !allowed.contains(&channel_id) - { - return Ok(()); + // Channel filter + if let Some(allowed) = perms.channel_filter.get(&team_id_str) + && !allowed.is_empty() + && !allowed.contains(&channel_id) + { + return Ok(()); + } } let conversation_id = if let Some(ref thread_ts) = msg_event.origin.thread_ts { @@ -1483,10 +1496,34 @@ fn split_message(text: &str, max_len: usize) -> Vec { chunks } -/// Sanitize an emoji name for Slack reactions (strip colons, lowercase). +/// Convert an emoji input to a Slack reaction short-code name. +/// +/// Handles three input forms: +/// 1. Unicode emoji (e.g. "👍") → looked up via the `emojis` crate → "thumbsup" +/// 2. Colon-wrapped short-code (e.g. ":thumbsup:") → stripped to "thumbsup" +/// 3. Plain short-code (e.g. "thumbsup") → passed through as-is fn sanitize_reaction_name(emoji: &str) -> String { - emoji - .trim() + let trimmed = emoji.trim(); + if let Some(emoji) = emojis::get(trimmed) { + if let Some(shortcode) = emoji.shortcode() { + // Note: shortcodes come from gemoji (GitHub's set) which may not match Slack's + // shortcode names for uncommon emojis. Common emojis (thumbsup, heart, etc.) are + // consistent across both sets. + tracing::debug!(unicode = trimmed, shortcode, "resolved unicode emoji to shortcode"); + return shortcode.to_string(); + } + // Unicode emoji matched but has no shortcode — use the emoji's name as fallback. + // Raw unicode would be rejected by Slack's reactions API. + let name = emoji.name().replace(' ', "_").to_lowercase(); + tracing::warn!( + unicode = trimmed, + fallback_name = %name, + "emoji matched but has no shortcode, using name as fallback" + ); + return name; + } + // Fall back to stripping colons and lowercasing (handles ":thumbsup:" and "thumbsup"). + trimmed .trim_start_matches(':') .trim_end_matches(':') .to_lowercase() @@ -1506,3 +1543,77 @@ fn resolve_slack_user_identity(user: &SlackUser, user_id: &str) -> SlackUserIden username, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_reaction_name_unicode_emoji_with_shortcode() { + // gemoji maps 👍 to "+1" — verify we get the shortcode, not the unicode back + let result = sanitize_reaction_name("\u{1F44D}"); // 👍 + assert_eq!(result, "+1", "should resolve unicode thumbs-up to its gemoji shortcode"); + } + + #[test] + fn sanitize_reaction_name_unicode_heart() { + let result = sanitize_reaction_name("\u{2764}\u{FE0F}"); // ❤️ + assert_eq!(result, "heart"); + } + + #[test] + fn sanitize_reaction_name_colon_wrapped_shortcode() { + let result = sanitize_reaction_name(":thumbsup:"); + assert_eq!(result, "thumbsup"); + } + + #[test] + fn sanitize_reaction_name_plain_shortcode() { + let result = sanitize_reaction_name("thumbsup"); + assert_eq!(result, "thumbsup"); + } + + #[test] + fn sanitize_reaction_name_colon_wrapped_uppercased() { + let result = sanitize_reaction_name(":ThumbsUp:"); + assert_eq!(result, "thumbsup"); + } + + #[test] + fn sanitize_reaction_name_whitespace_trimmed() { + let result = sanitize_reaction_name(" :fire: "); + // After trim, this won't match emojis::get (it's a shortcode string), + // so falls through to colon-stripping path + assert_eq!(result, "fire"); + } + + #[test] + fn sanitize_reaction_name_unicode_emoji_without_shortcode() { + // The emojis crate may have entries without shortcodes. + // Find one programmatically to keep the test resilient. + let emoji_without_shortcode = emojis::iter().find(|e| e.shortcode().is_none()); + if let Some(emoji) = emoji_without_shortcode { + let result = sanitize_reaction_name(emoji.as_str()); + let expected = emoji.name().replace(' ', "_").to_lowercase(); + assert_eq!( + result, expected, + "emoji without shortcode should fall back to name with underscores" + ); + } + // If all emojis have shortcodes, the fallback path is untestable + // with real data, but the code path still exists for safety. + } + + #[test] + fn sanitize_reaction_name_custom_slack_emoji() { + // Custom Slack emojis come as plain names like "partyparrot" + let result = sanitize_reaction_name("partyparrot"); + assert_eq!(result, "partyparrot"); + } + + #[test] + fn sanitize_reaction_name_custom_with_colons() { + let result = sanitize_reaction_name(":partyparrot:"); + assert_eq!(result, "partyparrot"); + } +} diff --git a/src/tools/browser.rs b/src/tools/browser.rs index e650a5ac3..597734f6c 100644 --- a/src/tools/browser.rs +++ b/src/tools/browser.rs @@ -4,9 +4,7 @@ //! via headless Chrome using chromiumoxide. Uses an accessibility-tree based //! ref system for LLM-friendly element addressing. -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use crate::config::BrowserConfig; - use chromiumoxide::browser::{Browser, BrowserConfig as ChromeConfig}; use chromiumoxide::page::ScreenshotParams; use chromiumoxide_cdp::cdp::browser_protocol::accessibility::{ @@ -17,11 +15,13 @@ use chromiumoxide_cdp::cdp::browser_protocol::input::{ }; use chromiumoxide_cdp::cdp::browser_protocol::page::CaptureScreenshotFormat; use futures::StreamExt as _; +use reqwest::Url; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; @@ -31,9 +31,8 @@ use tokio::task::JoinHandle; /// Blocks private/loopback IPs, link-local addresses, and cloud metadata endpoints /// to prevent server-side request forgery. fn validate_url(url: &str) -> Result<(), BrowserError> { - let parsed = url::Url::parse(url).map_err(|error| { - BrowserError::new(format!("invalid URL '{url}': {error}")) - })?; + let parsed = Url::parse(url) + .map_err(|error| BrowserError::new(format!("invalid URL '{url}': {error}")))?; match parsed.scheme() { "http" | "https" => {} From f5f410aaf240873ef3fe962b3a76c2258735567f Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Sun, 22 Feb 2026 18:12:30 -0500 Subject: [PATCH 2/3] fix: resolve unclosed delimiter in src/config.rs --- src/config.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/config.rs b/src/config.rs index 79529860f..65266422f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4373,6 +4373,8 @@ bind = "127.0.0.1" let bindings = vec![slack_binding(None, vec![])]; let perms = SlackPermissions::from_config(&config, &bindings); assert!(perms.workspace_filter.is_none()); + } + #[test] fn test_cron_timezone_resolution_precedence() { let _lock = env_test_lock() From e6cdfc7367c4ee3543c23aec6036681e1c3a79fc Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Sun, 22 Feb 2026 18:15:29 -0500 Subject: [PATCH 3/3] fix: run cargo fmt --- .githooks/pre-commit | 2 +- src/agent/channel.rs | 64 ++++++++++++++++++------------- src/agent/cortex_chat.rs | 34 ++++++++++------ src/agent/worker.rs | 8 ++-- src/api/bindings.rs | 32 +++++----------- src/api/server.rs | 8 +--- src/config.rs | 81 +++++++++++++++++++++++++++++---------- src/hooks/spacebot.rs | 10 ++--- src/llm/model.rs | 21 ++++++++-- src/llm/pricing.rs | 12 +++--- src/main.rs | 10 ++--- src/memory/lance.rs | 10 ++--- src/messaging/slack.rs | 69 ++++++++++++++++++++++++--------- src/messaging/twitch.rs | 2 +- src/secrets/store.rs | 2 +- src/skills.rs | 5 ++- src/telemetry/registry.rs | 13 ++++--- src/tools/file.rs | 5 ++- src/tools/shell.rs | 18 +++++++-- 19 files changed, 257 insertions(+), 149 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index a3dbafa61..5ce940e00 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -euo pipefail diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 7c3c08193..03c06467e 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -391,7 +391,10 @@ impl Channel { if messages.len() == 1 { // Single message - process normally - let message = messages.into_iter().next().ok_or_else(|| anyhow::anyhow!("empty iterator after length check"))?; + let message = messages + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("empty iterator after length check"))?; self.handle_message(message).await } else { // Multiple messages - batch them @@ -462,10 +465,11 @@ impl Channel { .get("telegram_chat_type") .and_then(|v| v.as_str()) }); - self.conversation_context = Some( - prompt_engine - .render_conversation_context(&first.source, server_name, channel_name)?, - ); + self.conversation_context = Some(prompt_engine.render_conversation_context( + &first.source, + server_name, + channel_name, + )?); } // Persist each message to conversation log (individual audit trail) @@ -605,8 +609,11 @@ impl Channel { let browser_enabled = rc.browser_config.load().enabled; let web_search_enabled = rc.brave_search_key.load().is_some(); let opencode_enabled = rc.opencode.load().enabled; - let worker_capabilities = - prompt_engine.render_worker_capabilities(browser_enabled, web_search_enabled, opencode_enabled)?; + let worker_capabilities = prompt_engine.render_worker_capabilities( + browser_enabled, + web_search_enabled, + opencode_enabled, + )?; let status_text = { let status = self.state.status_block.read().await; @@ -712,10 +719,11 @@ impl Channel { .get("telegram_chat_type") .and_then(|v| v.as_str()) }); - self.conversation_context = Some( - prompt_engine - .render_conversation_context(&message.source, server_name, channel_name)?, - ); + self.conversation_context = Some(prompt_engine.render_conversation_context( + &message.source, + server_name, + channel_name, + )?); } let system_prompt = self.build_system_prompt().await?; @@ -802,8 +810,11 @@ impl Channel { let browser_enabled = rc.browser_config.load().enabled; let web_search_enabled = rc.brave_search_key.load().is_some(); let opencode_enabled = rc.opencode.load().enabled; - let worker_capabilities = prompt_engine - .render_worker_capabilities(browser_enabled, web_search_enabled, opencode_enabled)?; + let worker_capabilities = prompt_engine.render_worker_capabilities( + browser_enabled, + web_search_enabled, + opencode_enabled, + )?; let status_text = { let status = self.state.status_block.read().await; @@ -814,17 +825,16 @@ impl Channel { let empty_to_none = |s: String| if s.is_empty() { None } else { Some(s) }; - prompt_engine - .render_channel_prompt( - empty_to_none(identity_context), - empty_to_none(memory_bulletin.to_string()), - empty_to_none(skills_prompt), - worker_capabilities, - self.conversation_context.clone(), - empty_to_none(status_text), - None, // coalesce_hint - only set for batched messages - available_channels, - ) + prompt_engine.render_channel_prompt( + empty_to_none(identity_context), + empty_to_none(memory_bulletin.to_string()), + empty_to_none(skills_prompt), + worker_capabilities, + self.conversation_context.clone(), + empty_to_none(status_text), + None, // coalesce_hint - only set for batched messages + available_channels, + ) } /// Register per-turn tools, run the LLM agentic loop, and clean up. @@ -1147,8 +1157,10 @@ impl Channel { for (key, value) in retrigger_metadata { self.pending_retrigger_metadata.insert(key, value); } - self.retrigger_deadline = - Some(tokio::time::Instant::now() + std::time::Duration::from_millis(RETRIGGER_DEBOUNCE_MS)); + self.retrigger_deadline = Some( + tokio::time::Instant::now() + + std::time::Duration::from_millis(RETRIGGER_DEBOUNCE_MS), + ); } } diff --git a/src/agent/cortex_chat.rs b/src/agent/cortex_chat.rs index ebff771b3..ed5ec0033 100644 --- a/src/agent/cortex_chat.rs +++ b/src/agent/cortex_chat.rs @@ -75,7 +75,8 @@ impl PromptHook for CortexChatHook { ) -> ToolCallHookAction { self.send(CortexChatEvent::ToolStarted { tool: tool_name.to_string(), - }).await; + }) + .await; ToolCallHookAction::Continue } @@ -95,7 +96,8 @@ impl PromptHook for CortexChatHook { self.send(CortexChatEvent::ToolCompleted { tool: tool_name.to_string(), result_preview: preview, - }).await; + }) + .await; HookAction::Continue } @@ -295,18 +297,22 @@ impl CortexChatSession { let _ = store .save_message(&thread_id, "assistant", &response, channel_ref) .await; - let _ = event_tx.send(CortexChatEvent::Done { - full_text: response, - }).await; + let _ = event_tx + .send(CortexChatEvent::Done { + full_text: response, + }) + .await; } Err(error) => { let error_text = format!("Cortex chat error: {error}"); let _ = store .save_message(&thread_id, "assistant", &error_text, channel_ref) .await; - let _ = event_tx.send(CortexChatEvent::Error { - message: error_text, - }).await; + let _ = event_tx + .send(CortexChatEvent::Error { + message: error_text, + }) + .await; } } }); @@ -314,7 +320,10 @@ impl CortexChatSession { Ok(event_rx) } - async fn build_system_prompt(&self, channel_context_id: Option<&str>) -> crate::error::Result { + async fn build_system_prompt( + &self, + channel_context_id: Option<&str>, + ) -> crate::error::Result { let runtime_config = &self.deps.runtime_config; let prompt_engine = runtime_config.prompts.load(); @@ -324,8 +333,11 @@ impl CortexChatSession { let browser_enabled = runtime_config.browser_config.load().enabled; let web_search_enabled = runtime_config.brave_search_key.load().is_some(); let opencode_enabled = runtime_config.opencode.load().enabled; - let worker_capabilities = - prompt_engine.render_worker_capabilities(browser_enabled, web_search_enabled, opencode_enabled)?; + let worker_capabilities = prompt_engine.render_worker_capabilities( + browser_enabled, + web_search_enabled, + opencode_enabled, + )?; // Load channel transcript if a channel context is active let channel_transcript = if let Some(channel_id) = channel_context_id { diff --git a/src/agent/worker.rs b/src/agent/worker.rs index 558956db7..ae369ad70 100644 --- a/src/agent/worker.rs +++ b/src/agent/worker.rs @@ -264,7 +264,10 @@ impl Worker { None } }) - .unwrap_or_else(|| "Worker reached maximum segments without a final response.".to_string()); + .unwrap_or_else(|| { + "Worker reached maximum segments without a final response." + .to_string() + }); } self.maybe_compact_history(&mut history).await; @@ -358,8 +361,7 @@ impl Worker { self.hook.send_status("compacting (overflow recovery)"); self.force_compact_history(&mut history).await; let prompt_engine = self.deps.runtime_config.prompts.load(); - let overflow_msg = - prompt_engine.render_system_worker_overflow()?; + let overflow_msg = prompt_engine.render_system_worker_overflow()?; follow_up_prompt = format!("{follow_up}\n\n{overflow_msg}"); } Err(error) => { diff --git a/src/api/bindings.rs b/src/api/bindings.rs index 3ea11acb2..eeb118a24 100644 --- a/src/api/bindings.rs +++ b/src/api/bindings.rs @@ -377,12 +377,10 @@ pub(super) async fn create_binding( Some(existing) => existing.clone(), None => { drop(perms_guard); - let Some(discord_config) = new_config - .messaging - .discord - .as_ref() - else { - tracing::error!("discord config missing despite token being provided"); + let Some(discord_config) = new_config.messaging.discord.as_ref() else { + tracing::error!( + "discord config missing despite token being provided" + ); return Err(StatusCode::INTERNAL_SERVER_ERROR); }; let perms = crate::config::DiscordPermissions::from_config( @@ -409,12 +407,10 @@ pub(super) async fn create_binding( Some(existing) => existing.clone(), None => { drop(perms_guard); - let Some(slack_config) = new_config - .messaging - .slack - .as_ref() - else { - tracing::error!("slack config missing despite tokens being provided"); + let Some(slack_config) = new_config.messaging.slack.as_ref() else { + tracing::error!( + "slack config missing despite tokens being provided" + ); return Err(StatusCode::INTERNAL_SERVER_ERROR); }; let perms = crate::config::SlackPermissions::from_config( @@ -453,11 +449,7 @@ pub(super) async fn create_binding( if let Some(token) = new_telegram_token { let telegram_perms = { - let Some(telegram_config) = new_config - .messaging - .telegram - .as_ref() - else { + let Some(telegram_config) = new_config.messaging.telegram.as_ref() else { tracing::error!("telegram config missing despite token being provided"); return Err(StatusCode::INTERNAL_SERVER_ERROR); }; @@ -475,11 +467,7 @@ pub(super) async fn create_binding( } if let Some((username, oauth_token)) = new_twitch_creds { - let Some(twitch_config) = new_config - .messaging - .twitch - .as_ref() - else { + let Some(twitch_config) = new_config.messaging.twitch.as_ref() else { tracing::error!("twitch config missing despite credentials being provided"); return Err(StatusCode::INTERNAL_SERVER_ERROR); }; diff --git a/src/api/server.rs b/src/api/server.rs index 99e6f8a33..d4f1eb6d6 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -14,8 +14,8 @@ use axum::middleware::{self, Next}; use axum::response::{Html, IntoResponse, Response}; use axum::routing::{delete, get, post, put}; use rust_embed::Embed; -use tower_http::cors::CorsLayer; use serde_json::json; +use tower_http::cors::CorsLayer; use std::net::SocketAddr; use std::sync::Arc; @@ -44,11 +44,7 @@ pub async fn start_http_server( axum::http::Method::DELETE, axum::http::Method::OPTIONS, ]) - .allow_headers([ - header::CONTENT_TYPE, - header::AUTHORIZATION, - header::ACCEPT, - ]); + .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION, header::ACCEPT]); let api_routes = Router::new() .route("/health", get(system::health)) diff --git a/src/config.rs b/src/config.rs index 65266422f..285ac0da5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -177,24 +177,66 @@ pub struct LlmConfig { impl std::fmt::Debug for LlmConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LlmConfig") - .field("anthropic_key", &self.anthropic_key.as_ref().map(|_| "[REDACTED]")) - .field("openai_key", &self.openai_key.as_ref().map(|_| "[REDACTED]")) - .field("openrouter_key", &self.openrouter_key.as_ref().map(|_| "[REDACTED]")) + .field( + "anthropic_key", + &self.anthropic_key.as_ref().map(|_| "[REDACTED]"), + ) + .field( + "openai_key", + &self.openai_key.as_ref().map(|_| "[REDACTED]"), + ) + .field( + "openrouter_key", + &self.openrouter_key.as_ref().map(|_| "[REDACTED]"), + ) .field("zhipu_key", &self.zhipu_key.as_ref().map(|_| "[REDACTED]")) .field("groq_key", &self.groq_key.as_ref().map(|_| "[REDACTED]")) - .field("together_key", &self.together_key.as_ref().map(|_| "[REDACTED]")) - .field("fireworks_key", &self.fireworks_key.as_ref().map(|_| "[REDACTED]")) - .field("deepseek_key", &self.deepseek_key.as_ref().map(|_| "[REDACTED]")) + .field( + "together_key", + &self.together_key.as_ref().map(|_| "[REDACTED]"), + ) + .field( + "fireworks_key", + &self.fireworks_key.as_ref().map(|_| "[REDACTED]"), + ) + .field( + "deepseek_key", + &self.deepseek_key.as_ref().map(|_| "[REDACTED]"), + ) .field("xai_key", &self.xai_key.as_ref().map(|_| "[REDACTED]")) - .field("mistral_key", &self.mistral_key.as_ref().map(|_| "[REDACTED]")) - .field("gemini_key", &self.gemini_key.as_ref().map(|_| "[REDACTED]")) - .field("ollama_key", &self.ollama_key.as_ref().map(|_| "[REDACTED]")) + .field( + "mistral_key", + &self.mistral_key.as_ref().map(|_| "[REDACTED]"), + ) + .field( + "gemini_key", + &self.gemini_key.as_ref().map(|_| "[REDACTED]"), + ) + .field( + "ollama_key", + &self.ollama_key.as_ref().map(|_| "[REDACTED]"), + ) .field("ollama_base_url", &self.ollama_base_url) - .field("opencode_zen_key", &self.opencode_zen_key.as_ref().map(|_| "[REDACTED]")) - .field("nvidia_key", &self.nvidia_key.as_ref().map(|_| "[REDACTED]")) - .field("minimax_key", &self.minimax_key.as_ref().map(|_| "[REDACTED]")) - .field("moonshot_key", &self.moonshot_key.as_ref().map(|_| "[REDACTED]")) - .field("zai_coding_plan_key", &self.zai_coding_plan_key.as_ref().map(|_| "[REDACTED]")) + .field( + "opencode_zen_key", + &self.opencode_zen_key.as_ref().map(|_| "[REDACTED]"), + ) + .field( + "nvidia_key", + &self.nvidia_key.as_ref().map(|_| "[REDACTED]"), + ) + .field( + "minimax_key", + &self.minimax_key.as_ref().map(|_| "[REDACTED]"), + ) + .field( + "moonshot_key", + &self.moonshot_key.as_ref().map(|_| "[REDACTED]"), + ) + .field( + "zai_coding_plan_key", + &self.zai_coding_plan_key.as_ref().map(|_| "[REDACTED]"), + ) .field("providers", &self.providers) .finish() } @@ -284,7 +326,10 @@ impl std::fmt::Debug for DefaultsConfig { .field("cortex", &self.cortex) .field("browser", &self.browser) .field("mcp", &self.mcp) - .field("brave_search_key", &self.brave_search_key.as_ref().map(|_| "[REDACTED]")) + .field( + "brave_search_key", + &self.brave_search_key.as_ref().map(|_| "[REDACTED]"), + ) .field("history_backfill_count", &self.history_backfill_count) .field("cron", &self.cron) .field("opencode", &self.opencode) @@ -2373,10 +2418,7 @@ impl Config { .into_iter() .map(|(provider_id, config)| { let api_key = resolve_env_value(&config.api_key).ok_or_else(|| { - anyhow::anyhow!( - "failed to resolve API key for provider '{}'", - provider_id - ) + anyhow::anyhow!("failed to resolve API key for provider '{}'", provider_id) })?; Ok(( provider_id.to_lowercase(), @@ -4275,7 +4317,6 @@ bind = "127.0.0.1" assert_eq!(config.api.bind, "[::]"); } - /// Helper to build a minimal `SlackConfig` for permission tests. fn slack_config_with_dm_users(dm_allowed_users: Vec) -> SlackConfig { SlackConfig { diff --git a/src/hooks/spacebot.rs b/src/hooks/spacebot.rs index 595780d76..60697c4f0 100644 --- a/src/hooks/spacebot.rs +++ b/src/hooks/spacebot.rs @@ -98,9 +98,8 @@ impl SpacebotHook { // Base64-wrapped secrets. Minimum 24 chars avoids false positives on // short alphanumeric strings while catching any encoded API key. - static BASE64_SEGMENT: LazyLock = LazyLock::new(|| { - Regex::new(r"[A-Za-z0-9+/]{24,}={0,2}").expect("hardcoded regex") - }); + static BASE64_SEGMENT: LazyLock = + LazyLock::new(|| Regex::new(r"[A-Za-z0-9+/]{24,}={0,2}").expect("hardcoded regex")); for segment in BASE64_SEGMENT.find_iter(content) { if let Ok(decoded_bytes) = base64::engine::general_purpose::STANDARD.decode(segment.as_str()) @@ -124,9 +123,8 @@ impl SpacebotHook { // Hex-encoded secrets. Minimum 40 hex chars (20 bytes) to reduce // false positives while catching any hex-wrapped API key. - static HEX_SEGMENT: LazyLock = LazyLock::new(|| { - Regex::new(r"(?i)(?:0x)?([0-9a-f]{40,})").expect("hardcoded regex") - }); + static HEX_SEGMENT: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)(?:0x)?([0-9a-f]{40,})").expect("hardcoded regex")); for caps in HEX_SEGMENT.captures_iter(content) { let hex_str = caps.get(1).map_or("", |m| m.as_str()); if let Ok(decoded_bytes) = hex::decode(hex_str) { diff --git a/src/llm/model.rs b/src/llm/model.rs index 2d52e914b..4d25e613e 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -341,16 +341,31 @@ impl CompletionModel for SpacebotModel { if usage.input_tokens > 0 || usage.output_tokens > 0 { metrics .llm_tokens_total - .with_label_values(&[agent_label, &self.full_model_name, tier_label, "input"]) + .with_label_values(&[ + agent_label, + &self.full_model_name, + tier_label, + "input", + ]) .inc_by(usage.input_tokens); metrics .llm_tokens_total - .with_label_values(&[agent_label, &self.full_model_name, tier_label, "output"]) + .with_label_values(&[ + agent_label, + &self.full_model_name, + tier_label, + "output", + ]) .inc_by(usage.output_tokens); if usage.cached_input_tokens > 0 { metrics .llm_tokens_total - .with_label_values(&[agent_label, &self.full_model_name, tier_label, "cached_input"]) + .with_label_values(&[ + agent_label, + &self.full_model_name, + tier_label, + "cached_input", + ]) .inc_by(usage.cached_input_tokens); } diff --git a/src/llm/pricing.rs b/src/llm/pricing.rs index 4717ffa48..721772fd1 100644 --- a/src/llm/pricing.rs +++ b/src/llm/pricing.rs @@ -41,13 +41,11 @@ fn lookup_pricing(model_name: &str) -> ModelPricing { output: per_m(15.0), cached_input: per_m(0.30), }, - m if m.starts_with("claude-3-5-haiku") || m.starts_with("claude-haiku-4") => { - ModelPricing { - input: per_m(0.80), - output: per_m(4.0), - cached_input: per_m(0.08), - } - } + m if m.starts_with("claude-3-5-haiku") || m.starts_with("claude-haiku-4") => ModelPricing { + input: per_m(0.80), + output: per_m(4.0), + cached_input: per_m(0.08), + }, m if m.starts_with("claude-3-opus") => ModelPricing { input: per_m(15.0), diff --git a/src/main.rs b/src/main.rs index ca8e4aa76..bd0090bb2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -610,11 +610,8 @@ async fn run( let (agent_remove_tx, mut agent_remove_rx) = mpsc::channel::(8); // Start HTTP API server if enabled - let mut api_state = spacebot::api::ApiState::new_with_provider_sender( - provider_tx, - agent_tx, - agent_remove_tx, - ); + let mut api_state = + spacebot::api::ApiState::new_with_provider_sender(provider_tx, agent_tx, agent_remove_tx); api_state.auth_token = config.api.auth_token.clone(); let api_state = Arc::new(api_state); @@ -1247,7 +1244,8 @@ async fn initialize_agents( ); // Per-agent memory system - let memory_store = spacebot::memory::MemoryStore::with_agent_id(db.sqlite.clone(), &agent_config.id); + let memory_store = + spacebot::memory::MemoryStore::with_agent_id(db.sqlite.clone(), &agent_config.id); let embedding_table = spacebot::memory::EmbeddingTable::open_or_create(&db.lance) .await .with_context(|| { diff --git a/src/memory/lance.rs b/src/memory/lance.rs index 0942a9387..382bedefc 100644 --- a/src/memory/lance.rs +++ b/src/memory/lance.rs @@ -361,12 +361,10 @@ impl EmbeddingTable { /// Validate that a memory ID is a well-formed UUID to prevent predicate injection. fn validate_memory_id(memory_id: &str) -> Result<()> { - if memory_id.len() != 36 - || !memory_id - .chars() - .all(|c| c.is_ascii_hexdigit() || c == '-') - { - return Err(DbError::LanceDb(format!("invalid memory ID format: {}", memory_id)).into()); + if memory_id.len() != 36 || !memory_id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') { + return Err( + DbError::LanceDb(format!("invalid memory ID format: {}", memory_id)).into(), + ); } Ok(()) } diff --git a/src/messaging/slack.rs b/src/messaging/slack.rs index 3000ba918..2aa1e3cbf 100644 --- a/src/messaging/slack.rs +++ b/src/messaging/slack.rs @@ -117,8 +117,18 @@ async fn handle_push_event( ) -> UserCallbackResult<()> { match event.event { SlackEventCallbackBody::Message(msg) => { - let channel = msg.origin.channel.as_ref().map(|c| c.0.as_str()).unwrap_or("none"); - let sender = msg.sender.user.as_ref().map(|u| u.0.as_str()).unwrap_or("none"); + let channel = msg + .origin + .channel + .as_ref() + .map(|c| c.0.as_str()) + .unwrap_or("none"); + let sender = msg + .sender + .user + .as_ref() + .map(|u| u.0.as_str()) + .unwrap_or("none"); let subtype = msg.subtype.as_ref().map(|s| format!("{:?}", s)); tracing::debug!(channel, sender, ?subtype, "slack push event: message"); handle_message_event(msg, &event.team_id, client, states).await @@ -148,9 +158,11 @@ async fn handle_message_event( let state_guard = states.read().await; let adapter_state = state_guard .get_user_state::>() - .ok_or_else(|| Box::::from( - "SlackAdapterState not found in user_state", - ))?; + .ok_or_else(|| { + Box::::from( + "SlackAdapterState not found in user_state", + ) + })?; let user_id = msg_event.sender.user.as_ref().map(|u| u.0.clone()); @@ -182,10 +194,18 @@ async fn handle_message_event( if let Some(ref sender_id) = user_id && !perms.dm_allowed_users.contains(sender_id) { - tracing::debug!(channel_id, user_id = sender_id.as_str(), "DM dropped: user not in dm_allowed_users"); + tracing::debug!( + channel_id, + user_id = sender_id.as_str(), + "DM dropped: user not in dm_allowed_users" + ); return Ok(()); } - tracing::info!(channel_id, ?user_id, "DM permitted, bypassing channel filter"); + tracing::info!( + channel_id, + ?user_id, + "DM permitted, bypassing channel filter" + ); } if !is_dm { @@ -255,9 +275,11 @@ async fn handle_app_mention_event( let state_guard = states.read().await; let adapter_state = state_guard .get_user_state::>() - .ok_or_else(|| Box::::from( - "SlackAdapterState not found in user_state", - ))?; + .ok_or_else(|| { + Box::::from( + "SlackAdapterState not found in user_state", + ) + })?; let user_id = mention.user.0.clone(); @@ -356,9 +378,11 @@ async fn handle_command_event( let state_guard = states.read().await; let adapter_state = state_guard .get_user_state::>() - .ok_or_else(|| Box::::from( - "SlackAdapterState not found in user_state", - ))?; + .ok_or_else(|| { + Box::::from( + "SlackAdapterState not found in user_state", + ) + })?; let command_str = event.command.0.clone(); let team_id = event.team_id.0.clone(); @@ -497,9 +521,11 @@ async fn handle_interaction_event( let state_guard = states.read().await; let adapter_state = state_guard .get_user_state::>() - .ok_or_else(|| Box::::from( - "SlackAdapterState not found in user_state", - ))?; + .ok_or_else(|| { + Box::::from( + "SlackAdapterState not found in user_state", + ) + })?; let user_id = block_actions .user @@ -1517,7 +1543,11 @@ fn sanitize_reaction_name(emoji: &str) -> String { // Note: shortcodes come from gemoji (GitHub's set) which may not match Slack's // shortcode names for uncommon emojis. Common emojis (thumbsup, heart, etc.) are // consistent across both sets. - tracing::debug!(unicode = trimmed, shortcode, "resolved unicode emoji to shortcode"); + tracing::debug!( + unicode = trimmed, + shortcode, + "resolved unicode emoji to shortcode" + ); return shortcode.to_string(); } // Unicode emoji matched but has no shortcode — use the emoji's name as fallback. @@ -1560,7 +1590,10 @@ mod tests { fn sanitize_reaction_name_unicode_emoji_with_shortcode() { // gemoji maps 👍 to "+1" — verify we get the shortcode, not the unicode back let result = sanitize_reaction_name("\u{1F44D}"); // 👍 - assert_eq!(result, "+1", "should resolve unicode thumbs-up to its gemoji shortcode"); + assert_eq!( + result, "+1", + "should resolve unicode thumbs-up to its gemoji shortcode" + ); } #[test] diff --git a/src/messaging/twitch.rs b/src/messaging/twitch.rs index 57fde883c..2efaa5ab9 100644 --- a/src/messaging/twitch.rs +++ b/src/messaging/twitch.rs @@ -16,7 +16,7 @@ use twitch_irc::{ClientConfig, SecureTCPTransport, TwitchIRCClient}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; -use tokio::sync::{mpsc, RwLock}; +use tokio::sync::{RwLock, mpsc}; #[derive(Serialize, Deserialize)] struct TwitchTokenFile { diff --git a/src/secrets/store.rs b/src/secrets/store.rs index 5b11681cc..d598be95d 100644 --- a/src/secrets/store.rs +++ b/src/secrets/store.rs @@ -1,7 +1,7 @@ //! Encrypted credentials storage (AES-256-GCM, redb). use crate::error::SecretsError; -use aes_gcm::{aead::Aead, Aes256Gcm, KeyInit, Nonce}; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead}; use rand::RngCore; use redb::{Database, ReadableTable, TableDefinition}; use sha2::{Digest, Sha256}; diff --git a/src/skills.rs b/src/skills.rs index d8aa20b4b..4b13759cf 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -115,7 +115,10 @@ impl SkillSet { /// /// The channel sees skill names and descriptions but is instructed to /// delegate actual skill execution to workers. - pub fn render_channel_prompt(&self, prompt_engine: &crate::prompts::PromptEngine) -> crate::error::Result { + pub fn render_channel_prompt( + &self, + prompt_engine: &crate::prompts::PromptEngine, + ) -> crate::error::Result { if self.skills.is_empty() { return Ok(String::new()); } diff --git a/src/telemetry/registry.rs b/src/telemetry/registry.rs index 4e6dc4666..4d3121d35 100644 --- a/src/telemetry/registry.rs +++ b/src/telemetry/registry.rs @@ -112,7 +112,9 @@ impl Metrics { "spacebot_llm_request_duration_seconds", "LLM request duration in seconds", ) - .buckets(vec![0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 15.0, 30.0, 60.0, 120.0]), + .buckets(vec![ + 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 15.0, 30.0, 60.0, 120.0, + ]), &["agent_id", "model", "tier"], ) .expect("hardcoded metric descriptor"); @@ -167,16 +169,15 @@ impl Metrics { "spacebot_worker_duration_seconds", "Worker lifetime duration in seconds", ) - .buckets(vec![1.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 600.0, 1800.0]), + .buckets(vec![ + 1.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 600.0, 1800.0, + ]), &["agent_id", "worker_type"], ) .expect("hardcoded metric descriptor"); let process_errors_total = IntCounterVec::new( - Opts::new( - "spacebot_process_errors_total", - "Process errors by type", - ), + Opts::new("spacebot_process_errors_total", "Process errors by type"), &["agent_id", "process_type", "error_type"], ) .expect("hardcoded metric descriptor"); diff --git a/src/tools/file.rs b/src/tools/file.rs index f8e9a49df..c8beef588 100644 --- a/src/tools/file.rs +++ b/src/tools/file.rs @@ -204,7 +204,10 @@ impl Tool for FileTool { // the dedicated identity API to keep update flow consistent. let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); const PROTECTED_FILES: &[&str] = &["SOUL.md", "IDENTITY.md", "USER.md"]; - if PROTECTED_FILES.iter().any(|f| file_name.eq_ignore_ascii_case(f)) { + if PROTECTED_FILES + .iter() + .any(|f| file_name.eq_ignore_ascii_case(f)) + { return Err(FileError( "ACCESS DENIED: Identity files are protected and cannot be modified \ through file operations. Use the identity management API instead." diff --git a/src/tools/shell.rs b/src/tools/shell.rs index a461d3572..32e93b81f 100644 --- a/src/tools/shell.rs +++ b/src/tools/shell.rs @@ -106,7 +106,9 @@ impl ShellTool { let after = pos + dollar_var.len(); let next_char = command[after..].chars().next(); // $VAR is a match if followed by non-alphanumeric/underscore or end-of-string - if next_char.is_none() || (!next_char.unwrap().is_alphanumeric() && next_char.unwrap() != '_') { + if next_char.is_none() + || (!next_char.unwrap().is_alphanumeric() && next_char.unwrap() != '_') + { return Err(ShellError { message: "Cannot access secret environment variables.".to_string(), exit_code: -1, @@ -184,11 +186,19 @@ impl ShellTool { } // Block interpreter one-liners that can bypass shell-level restrictions - for interpreter in ["python3 -c", "python -c", "perl -e", "ruby -e", "node -e", "node --eval"] { + for interpreter in [ + "python3 -c", + "python -c", + "perl -e", + "ruby -e", + "node -e", + "node --eval", + ] { if command.contains(interpreter) { return Err(ShellError { - message: "Inline interpreter execution is not permitted — use script files instead." - .to_string(), + message: + "Inline interpreter execution is not permitted — use script files instead." + .to_string(), exit_code: -1, }); }