diff --git a/.env.example b/.env.example index 765ea3f652..40b18c7c05 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ # Database Configuration +# For Docker: use host.docker.internal instead of localhost to access services on the host machine DATABASE_URL=postgres://localhost/ironclaw DATABASE_POOL_SIZE=10 @@ -38,12 +39,14 @@ NEARAI_AUTH_URL=https://private.near.ai # === Ollama === # OLLAMA_MODEL=llama3.2 # LLM_BACKEND=ollama -# OLLAMA_BASE_URL=http://localhost:11434 # default +# For Docker: use host.docker.internal:11434 to access Ollama on the host machine +# OLLAMA_BASE_URL=http://host.docker.internal:11434 # === OpenAI-compatible (LM Studio, vLLM, Anything-LLM) === # LLM_MODEL=llama-3.2-3b-instruct-q4_K_M # LLM_BACKEND=openai_compatible -# LLM_BASE_URL=http://localhost:1234/v1 +# For Docker: use host.docker.internal:1234 to access LM Studio on the host machine +# LLM_BASE_URL=http://host.docker.internal:1234/v1 # LLM_API_KEY=sk-... # optional for local servers # Custom HTTP headers for OpenAI-compatible providers # Format: comma-separated key:value pairs @@ -99,6 +102,7 @@ SLACK_SIGNING_SECRET=... # Telegram Bot (optional) TELEGRAM_BOT_TOKEN=... +# TELEGRAM_POLLING_ENABLED=true # force Telegram to use getUpdates polling instead of webhooks # HTTP Webhook Server (optional) HTTP_HOST=0.0.0.0 diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml index b50557171c..9956c68127 100644 --- a/.github/workflows/code_style.yml +++ b/.github/workflows/code_style.yml @@ -89,34 +89,7 @@ jobs: - name: Check for .unwrap(), .expect(), assert!() in production code run: | BASE="${{ github.event.pull_request.base.sha }}" - # Get added lines in .rs files (production only, exclude tests/) - ADDED=$(git diff "$BASE"...HEAD -- 'src/**/*.rs' 'crates/**/*.rs' \ - | grep -E '^\+[^+]' || true) - - if [ -z "$ADDED" ]; then - echo "No production Rust changes detected." - exit 0 - fi - - # Match panic-inducing patterns, excluding test code and safety suppressions - VIOLATIONS=$(echo "$ADDED" \ - | grep -E '\.(unwrap|expect)\(|[^_]assert(_eq|_ne)?!' \ - | grep -Ev 'debug_assert|// safety:|#\[cfg\(test\)\]|#\[test\]|mod tests' \ - || true) - - if [ -n "$VIOLATIONS" ]; then - echo "::error::Found .unwrap(), .expect(), or assert!() in production code." - echo "Production code must use proper error handling instead of panicking." - echo "Suppress false positives with an inline '// safety: ' comment." - echo "" - echo "$VIOLATIONS" | head -20 - echo "" - COUNT=$(echo "$VIOLATIONS" | wc -l | tr -d ' ') - echo "Total: $COUNT violation(s)" - exit 1 - fi - - echo "OK: No panic-inducing calls in changed production code." + python3 scripts/check_no_panics.py --base "$BASE" --head HEAD # Roll-up job for branch protection code-style: diff --git a/Cargo.lock b/Cargo.lock index c6b3e6f1ff..6d5226ab1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2077,7 +2077,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2264,7 +2264,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3110,7 +3110,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -4089,7 +4089,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4879,7 +4879,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.37", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -4916,9 +4916,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -5433,7 +5433,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6340,7 +6340,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7134,9 +7134,9 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "uds_windows" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", @@ -7996,7 +7996,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs index d8718ebb91..b0746e7e0e 100644 --- a/channels-src/telegram/src/lib.rs +++ b/channels-src/telegram/src/lib.rs @@ -1899,21 +1899,51 @@ fn clean_message_text(text: &str, bot_username: Option<&str>) -> String { result } +fn slash_command_token(text: &str) -> Option<&str> { + let trimmed = text.trim(); + if !trimmed.starts_with('/') { + return None; + } + + let end = trimmed.find(char::is_whitespace).unwrap_or(trimmed.len()); + Some(&trimmed[..end]) +} + +fn is_start_command_token(command: &str, bot_username: Option<&str>) -> bool { + if command.eq_ignore_ascii_case("/start") { + return true; + } + + if let Some(bot) = bot_username { + let expected = format!("/start@{}", bot); + command.eq_ignore_ascii_case(&expected) + } else { + command.len() > "/start@".len() && command[..7].eq_ignore_ascii_case("/start@") + } +} + /// Decide which user content should be emitted to the agent loop. /// /// - `/start` emits a placeholder so the agent can greet the user -/// - bare slash commands are passed through for Submission parsing +/// - slash commands are passed through for Submission parsing +/// - `/start ` keeps legacy behavior and emits only the payload /// - empty/mention-only messages are ignored /// - otherwise cleaned text is emitted fn content_to_emit_for_agent(content: &str, bot_username: Option<&str>) -> Option { - let cleaned_text = clean_message_text(content, bot_username); let trimmed_content = content.trim(); + let cleaned_text = clean_message_text(content, bot_username); - if trimmed_content.eq_ignore_ascii_case("/start") { - return Some("[User started the bot]".to_string()); - } + if let Some(command) = slash_command_token(trimmed_content) { + if is_start_command_token(command, bot_username) { + if trimmed_content.eq_ignore_ascii_case(command) { + return Some("[User started the bot]".to_string()); + } + + if !cleaned_text.is_empty() { + return Some(cleaned_text); + } + } - if cleaned_text.is_empty() && trimmed_content.starts_with('/') { return Some(trimmed_content.to_string()); } @@ -2007,12 +2037,20 @@ mod tests { content_to_emit_for_agent(" /start ", None), Some("[User started the bot]".to_string()) ); + assert_eq!( + content_to_emit_for_agent("/start@MyBot", Some("MyBot")), + Some("[User started the bot]".to_string()) + ); // /start with args → pass args through assert_eq!( content_to_emit_for_agent("/start hello", None), Some("hello".to_string()) ); + assert_eq!( + content_to_emit_for_agent("/start@MyBot hello", Some("MyBot")), + Some("hello".to_string()) + ); // Control commands → pass through raw so Submission::parse() can match assert_eq!( @@ -2076,10 +2114,18 @@ mod tests { Some("/no".to_string()) ); - // Commands with args → cleaned text (command stripped) + // Commands with args must pass through raw so Submission::parse() can match. assert_eq!( content_to_emit_for_agent("/help me please", None), - Some("me please".to_string()) + Some("/help me please".to_string()) + ); + assert_eq!( + content_to_emit_for_agent("/model claude-opus-4-6", None), + Some("/model claude-opus-4-6".to_string()) + ); + assert_eq!( + content_to_emit_for_agent("/model@MyBot qwen3.5:9b-q8_0", Some("MyBot")), + Some("/model@MyBot qwen3.5:9b-q8_0".to_string()) ); // Plain text → pass through diff --git a/channels-src/whatsapp/Cargo.lock b/channels-src/whatsapp/Cargo.lock index 0e55d1e532..adefa9aa3b 100644 --- a/channels-src/whatsapp/Cargo.lock +++ b/channels-src/whatsapp/Cargo.lock @@ -269,7 +269,7 @@ dependencies = [ [[package]] name = "whatsapp-channel" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde", "serde_json", diff --git a/docker-compose.yml b/docker-compose.yml index e3e6f5786e..18c1be6694 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: postgres: image: pgvector/pgvector:pg16 ports: - - "127.0.0.1:5432:5432" + - "5432:5432" environment: POSTGRES_DB: ironclaw POSTGRES_USER: ironclaw @@ -16,5 +16,25 @@ services: timeout: 3s retries: 5 + app: + image: ironclaw:latest + depends_on: + postgres: + condition: service_healthy + ports: + - "3231:3000" + - "8281:8080" + env_file: + - .env + environment: + # Override DB connection to use service name (DNS resolution) + DATABASE_URL: postgres://ironclaw:ironclaw@postgres:5432/ironclaw + volumes: + - ./extensions/ironclaw-home:/home/ironclaw/.ironclaw + - ./tools-src:/app/tools-src:ro + - ./channels-src:/app/channels-src:ro + networks: + - default + volumes: pgdata: diff --git a/scripts/check_no_panics.py b/scripts/check_no_panics.py new file mode 100644 index 0000000000..6202829946 --- /dev/null +++ b/scripts/check_no_panics.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 + +import argparse +import pathlib +import re +import subprocess +import sys +import unittest +from dataclasses import dataclass + + +PANIC_PATTERN = re.compile(r"\.(?:unwrap|expect)\(|(? str: + result = subprocess.run( + ["git", *args], + check=True, + capture_output=True, + text=True, + ) + return result.stdout + + +def sanitize_line(line: str, state: LexerState) -> str: + chars = list(line) + out = [" "] * len(chars) + i = 0 + + while i < len(chars): + ch = chars[i] + nxt = chars[i + 1] if i + 1 < len(chars) else "" + + if state.block_comment_depth: + if ch == "/" and nxt == "*": + state.block_comment_depth += 1 + i += 2 + continue + if ch == "*" and nxt == "/": + state.block_comment_depth -= 1 + i += 2 + continue + i += 1 + continue + + if state.raw_string_hashes is not None: + if ch == '"': + hashes = 0 + j = i + 1 + while j < len(chars) and chars[j] == "#": + hashes += 1 + j += 1 + if hashes == state.raw_string_hashes: + state.raw_string_hashes = None + i = j + continue + i += 1 + continue + + if state.in_string: + if state.string_escape: + state.string_escape = False + elif ch == "\\": + state.string_escape = True + elif ch == '"': + state.in_string = False + i += 1 + continue + + if state.in_char: + if state.char_escape: + state.char_escape = False + elif ch == "\\": + state.char_escape = True + elif ch == "'": + state.in_char = False + i += 1 + continue + + if ch == "/" and nxt == "/": + break + if ch == "/" and nxt == "*": + state.block_comment_depth += 1 + i += 2 + continue + if ch == "r": + j = i + 1 + while j < len(chars) and chars[j] == "#": + j += 1 + if j < len(chars) and chars[j] == '"': + state.raw_string_hashes = j - i - 1 + i = j + 1 + continue + if ch == '"': + state.in_string = True + i += 1 + continue + if ch == "'": + state.in_char = True + i += 1 + continue + + out[i] = ch + i += 1 + + return "".join(out) + + +def is_test_item(line: str, pending_test_attr: bool) -> tuple[bool, bool]: + match = ITEM_PATTERN.match(line) + if not match: + return False, False + + kind, name = match.groups() + named_tests_module = kind == "mod" and name == "tests" + return True, pending_test_attr or named_tests_module + + +def line_test_contexts(lines: list[str]) -> list[bool]: + contexts = [False] * len(lines) + lexer = LexerState() + block_stack: list[bool] = [] + pending_test_attr = False + pending_block_context: bool | None = None + + for idx, raw in enumerate(lines): + code = sanitize_line(raw, lexer) + stripped = code.strip() + current_context = block_stack[-1] if block_stack else False + + if TEST_ATTR_PATTERN.match(stripped): + pending_test_attr = True + + item_found, item_is_test = is_test_item(code, pending_test_attr) + if item_found: + pending_block_context = item_is_test or current_context + pending_test_attr = False + elif stripped and not stripped.startswith("#[") and pending_test_attr: + pending_test_attr = False + + contexts[idx] = current_context or bool(pending_block_context) + + for ch in code: + if ch == "{": + if pending_block_context is not None: + block_stack.append(pending_block_context) + pending_block_context = None + else: + block_stack.append(block_stack[-1] if block_stack else False) + elif ch == "}" and block_stack: + block_stack.pop() + + if stripped.endswith(";"): + pending_block_context = None + + return contexts + + +def changed_rust_files(base: str, head: str) -> list[pathlib.Path]: + output = run_git("diff", "--name-only", f"{base}...{head}", "--", "src", "crates") + files = [] + for line in output.splitlines(): + if line.endswith(".rs") and (line.startswith("src/") or line.startswith("crates/")): + files.append(pathlib.Path(line)) + return files + + +def added_lines_for_file(base: str, head: str, path: pathlib.Path) -> set[int]: + diff = run_git("diff", "--unified=0", f"{base}...{head}", "--", str(path)) + added: set[int] = set() + current_line = 0 + + for line in diff.splitlines(): + if line.startswith("@@"): + match = re.search(r"\+(\d+)(?:,(\d+))?", line) + if not match: + continue + current_line = int(match.group(1)) + continue + if line.startswith("+++ ") or line.startswith("--- "): + continue + if line.startswith("+"): + added.add(current_line) + current_line += 1 + elif line.startswith("-"): + continue + else: + current_line += 1 + + return added + + +def collect_violations(base: str, head: str) -> list[tuple[str, int, str]]: + violations: list[tuple[str, int, str]] = [] + + for path in changed_rust_files(base, head): + if not path.exists(): + continue + added_lines = added_lines_for_file(base, head, path) + if not added_lines: + continue + + lines = path.read_text(encoding="utf-8").splitlines() + contexts = line_test_contexts(lines) + lexer = LexerState() + sanitized = [sanitize_line(line, lexer) for line in lines] + + for line_no in sorted(added_lines): + if line_no < 1 or line_no > len(lines): + continue + if contexts[line_no - 1]: + continue + if "// safety:" in lines[line_no - 1]: + continue + if PANIC_PATTERN.search(sanitized[line_no - 1]): + violations.append((str(path), line_no, lines[line_no - 1].rstrip())) + + return violations + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base", required=False, default="origin/staging") + parser.add_argument("--head", required=False, default="HEAD") + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args() + + if args.self_test: + suite = unittest.defaultTestLoader.loadTestsFromTestCase(CheckNoPanicsTests) + result = unittest.TextTestRunner(verbosity=2).run(suite) + return 0 if result.wasSuccessful() else 1 + + violations = collect_violations(args.base, args.head) + if not violations: + print("OK: No panic-inducing calls in changed production code.") + return 0 + + print("::error::Found panic-style calls outside test-only Rust code.") + print("Production code must use proper error handling instead of panicking.") + print("") + for path, line_no, line in violations[:20]: + print(f"{path}:{line_no}: {line}") + print("") + print(f"Total: {len(violations)} violation(s)") + return 1 + + +class CheckNoPanicsTests(unittest.TestCase): + def test_cfg_test_module_marks_inner_lines(self) -> None: + lines = [ + "#[cfg(test)]\n", + "mod tests {\n", + " assert!(true);\n", + "}\n", + "fn prod() {\n", + " value.expect(\"boom\");\n", + "}\n", + ] + + contexts = line_test_contexts(lines) + + self.assertTrue(contexts[1]) + self.assertTrue(contexts[2]) + self.assertFalse(contexts[4]) + self.assertFalse(contexts[5]) + + def test_test_function_marks_body_only(self) -> None: + lines = [ + "#[test]\n", + "fn it_works(\n", + ") {\n", + " assert_eq!(2 + 2, 4);\n", + "}\n", + "fn prod() {\n", + " assert!(ready);\n", + "}\n", + ] + + contexts = line_test_contexts(lines) + + self.assertTrue(contexts[1]) + self.assertTrue(contexts[2]) + self.assertTrue(contexts[3]) + self.assertFalse(contexts[5]) + self.assertFalse(contexts[6]) + + def test_named_tests_module_without_cfg_is_ignored(self) -> None: + lines = [ + "mod tests {\n", + " fn helper() {\n", + " assert!(true);\n", + " }\n", + "}\n", + ] + + contexts = line_test_contexts(lines) + + self.assertTrue(all(contexts)) + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/src/channels/wasm/bundled.rs b/src/channels/wasm/bundled.rs index eb3675b744..cb5b7ff4d3 100644 --- a/src/channels/wasm/bundled.rs +++ b/src/channels/wasm/bundled.rs @@ -134,6 +134,13 @@ pub fn available_channel_names() -> Vec<&'static str> { .collect() } +/// Locate the capabilities sidecar for a known bundled channel. +pub(crate) fn locate_bundled_channel_capabilities(name: &str) -> Option { + locate_channel_artifacts(name) + .ok() + .map(|(_, caps_path)| caps_path) +} + #[cfg(test)] mod tests { use tempfile::tempdir; @@ -161,6 +168,11 @@ mod tests { assert!(locate_channel_artifacts("nonexistent").is_err()); } + #[test] + fn test_locate_bundled_channel_capabilities_unknown_channel() { + assert!(locate_bundled_channel_capabilities("nonexistent").is_none()); + } + #[tokio::test] async fn test_install_refuses_overwrite_without_force() { let dir = tempdir().unwrap(); diff --git a/src/channels/wasm/loader.rs b/src/channels/wasm/loader.rs index c261193e7d..8c581243c6 100644 --- a/src/channels/wasm/loader.rs +++ b/src/channels/wasm/loader.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use tokio::fs; use crate::bootstrap::ironclaw_base_dir; +use crate::channels::wasm::bundled::locate_bundled_channel_capabilities; use crate::channels::wasm::capabilities::ChannelCapabilities; use crate::channels::wasm::error::WasmChannelError; use crate::channels::wasm::runtime::WasmChannelRuntime; @@ -21,6 +22,22 @@ use crate::db::SettingsStore; use crate::pairing::PairingStore; use crate::secrets::SecretsStore; +fn resolve_capabilities_path(name: &str, wasm_path: &Path) -> Option { + let local_cap_path = wasm_path.with_extension("capabilities.json"); + if local_cap_path.exists() { + return Some(local_cap_path); + } + + let bundled_cap_path = locate_bundled_channel_capabilities(name)?; + tracing::warn!( + channel = name, + local_wasm = %wasm_path.display(), + fallback_capabilities = %bundled_cap_path.display(), + "Installed channel is missing local capabilities sidecar; falling back to bundled capabilities" + ); + Some(bundled_cap_path) +} + /// Loads WASM channels from the filesystem. pub struct WasmChannelLoader { runtime: Arc, @@ -229,9 +246,8 @@ impl WasmChannelLoader { } }; - let cap_path = path.with_extension("capabilities.json"); - let has_cap = cap_path.exists(); - channel_entries.push((name, path, if has_cap { Some(cap_path) } else { None })); + let cap_path = resolve_capabilities_path(&name, &path); + channel_entries.push((name, path, cap_path)); } // Load all channels in parallel (file I/O + WASM compilation) @@ -413,13 +429,14 @@ pub fn default_channels_dir() -> PathBuf { #[cfg(test)] mod tests { use std::io::Write; + use std::path::PathBuf; + use std::sync::Arc; use tempfile::TempDir; use crate::channels::wasm::loader::{WasmChannelLoader, discover_channels}; use crate::channels::wasm::runtime::{WasmChannelRuntime, WasmChannelRuntimeConfig}; use crate::pairing::PairingStore; - use std::sync::Arc; #[tokio::test] async fn test_discover_channels_empty_dir() { @@ -517,4 +534,51 @@ mod tests { assert!(results.loaded.is_empty()); assert!(results.errors.is_empty()); } + + #[test] + fn resolve_capabilities_path_prefers_local_sidecar() { + let dir = TempDir::new().unwrap(); + let wasm_path = dir.path().join("telegram.wasm"); + let cap_path = dir.path().join("telegram.capabilities.json"); + std::fs::File::create(&wasm_path).unwrap(); + std::fs::File::create(&cap_path).unwrap(); + + let resolved = super::resolve_capabilities_path("telegram", &wasm_path); + assert_eq!(resolved, Some(cap_path)); + } + + #[test] + fn resolve_capabilities_path_falls_back_to_bundled_sidecar() { + let install_dir = TempDir::new().unwrap(); + let bundled_root = TempDir::new().unwrap(); + let channel_dir = bundled_root.path().join("telegram"); + let target_dir = channel_dir.join(PathBuf::from("target/wasm32-wasip2/release")); + std::fs::create_dir_all(&target_dir).unwrap(); + + let installed_wasm = install_dir.path().join("telegram.wasm"); + std::fs::File::create(&installed_wasm).unwrap(); + + let bundled_wasm = target_dir.join("telegram_channel.wasm"); + let bundled_cap = channel_dir.join("telegram.capabilities.json"); + std::fs::File::create(&bundled_wasm).unwrap(); + std::fs::File::create(&bundled_cap).unwrap(); + + let previous = std::env::var_os("IRONCLAW_CHANNELS_SRC"); + unsafe { + std::env::set_var("IRONCLAW_CHANNELS_SRC", bundled_root.path()); + } + + let resolved = super::resolve_capabilities_path("telegram", &installed_wasm); + + match previous { + Some(value) => unsafe { + std::env::set_var("IRONCLAW_CHANNELS_SRC", value); + }, + None => unsafe { + std::env::remove_var("IRONCLAW_CHANNELS_SRC"); + }, + } + + assert_eq!(resolved, Some(bundled_cap)); + } } diff --git a/src/channels/wasm/setup.rs b/src/channels/wasm/setup.rs index cf448750bc..5db916763a 100644 --- a/src/channels/wasm/setup.rs +++ b/src/channels/wasm/setup.rs @@ -135,7 +135,7 @@ async fn register_channel( let channel_arc = Arc::new(loaded.channel); - // Inject runtime config (tunnel URL, webhook secret, owner_id). + // Inject runtime config (tunnel URL, webhook secret, owner_id, polling override). { let mut config_updates = std::collections::HashMap::new(); @@ -161,12 +161,21 @@ async fn register_channel( config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id)); } + if channel_name == "telegram" && config.channels.telegram_polling_enabled { + config_updates.insert("polling_enabled".to_string(), serde_json::Value::Bool(true)); + } + if !config_updates.is_empty() { channel_arc.update_config(config_updates).await; tracing::info!( channel = %channel_name, has_tunnel = config.tunnel.public_url.is_some(), has_webhook_secret = webhook_secret.is_some(), + polling_enabled = if channel_name == "telegram" { + Some(config.channels.telegram_polling_enabled) + } else { + None + }, "Injected runtime config into channel" ); } diff --git a/src/config/channels.rs b/src/config/channels.rs index 90635c22f5..7cdc752e46 100644 --- a/src/config/channels.rs +++ b/src/config/channels.rs @@ -22,6 +22,8 @@ pub struct ChannelsConfig { /// Per-channel owner user IDs. When set, the channel only responds to this user. /// Key: channel name (e.g., "telegram"), Value: owner user ID. pub wasm_channel_owner_ids: HashMap, + /// Force the Telegram WASM channel to use polling mode even when a tunnel URL is configured. + pub telegram_polling_enabled: bool, } #[derive(Debug, Clone)] @@ -196,6 +198,7 @@ impl ChannelsConfig { } ids }, + telegram_polling_enabled: parse_bool_env("TELEGRAM_POLLING_ENABLED", false)?, }) } } @@ -324,6 +327,7 @@ mod tests { wasm_channels_dir: PathBuf::from("/tmp/channels"), wasm_channels_enabled: true, wasm_channel_owner_ids: HashMap::new(), + telegram_polling_enabled: false, }; assert!(cfg.cli.enabled); assert!(cfg.http.is_none()); @@ -348,12 +352,28 @@ mod tests { wasm_channels_dir: PathBuf::from("/opt/channels"), wasm_channels_enabled: false, wasm_channel_owner_ids: ids, + telegram_polling_enabled: false, }; assert_eq!(cfg.wasm_channel_owner_ids.get("telegram"), Some(&12345)); assert_eq!(cfg.wasm_channel_owner_ids.get("slack"), Some(&67890)); assert!(!cfg.wasm_channels_enabled); } + #[test] + fn channels_config_telegram_polling_flag() { + let cfg = ChannelsConfig { + cli: CliConfig { enabled: true }, + http: None, + gateway: None, + signal: None, + wasm_channels_dir: PathBuf::from("/tmp/channels"), + wasm_channels_enabled: true, + wasm_channel_owner_ids: HashMap::new(), + telegram_polling_enabled: true, + }; + assert!(cfg.telegram_polling_enabled); + } + #[test] fn default_channels_dir_ends_with_channels() { let dir = default_channels_dir(); diff --git a/src/config/mod.rs b/src/config/mod.rs index 34c34423ac..d8f01a446f 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -136,6 +136,7 @@ impl Config { wasm_channels_dir: std::env::temp_dir().join("ironclaw-test-channels"), wasm_channels_enabled: false, wasm_channel_owner_ids: HashMap::new(), + telegram_polling_enabled: false, }, agent: AgentConfig::for_testing(), safety: SafetyConfig { diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 6488caa548..c452b083c7 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -485,7 +485,7 @@ impl ExtensionManager { ExtensionKind::McpServer => self.install_mcp_from_url(name, url).await, ExtensionKind::WasmTool => self.install_wasm_tool_from_url(name, url).await, ExtensionKind::WasmChannel => { - self.install_wasm_channel_from_url(name, url, None).await + self.install_wasm_channel_from_url(name, url, None, None).await } ExtensionKind::ChannelRelay => { // ChannelRelay extensions are installed from registry, not by URL @@ -1327,10 +1327,12 @@ impl ExtensionManager { wasm_url, capabilities_url, } => { + let archive_crate_name = preferred_archive_crate_name(entry, source); self.install_wasm_tool_from_url_with_caps( &entry.name, wasm_url, capabilities_url.as_deref(), + archive_crate_name, ) .await } @@ -1357,10 +1359,12 @@ impl ExtensionManager { wasm_url, capabilities_url, } => { + let archive_crate_name = preferred_archive_crate_name(entry, source); self.install_wasm_channel_from_url( &entry.name, wasm_url, capabilities_url.as_deref(), + archive_crate_name, ) .await } @@ -1436,7 +1440,7 @@ impl ExtensionManager { name: &str, url: &str, ) -> Result { - self.install_wasm_tool_from_url_with_caps(name, url, None) + self.install_wasm_tool_from_url_with_caps(name, url, None, None) .await } @@ -1445,9 +1449,16 @@ impl ExtensionManager { name: &str, url: &str, capabilities_url: Option<&str>, + archive_crate_name: Option<&str>, ) -> Result { - self.download_and_install_wasm(name, url, capabilities_url, &self.wasm_tools_dir) - .await?; + self.download_and_install_wasm( + name, + archive_crate_name, + url, + capabilities_url, + &self.wasm_tools_dir, + ) + .await?; Ok(InstallResult { name: name.to_string(), @@ -1461,9 +1472,16 @@ impl ExtensionManager { name: &str, url: &str, capabilities_url: Option<&str>, + archive_crate_name: Option<&str>, ) -> Result { - self.download_and_install_wasm(name, url, capabilities_url, &self.wasm_channels_dir) - .await?; + self.download_and_install_wasm( + name, + archive_crate_name, + url, + capabilities_url, + &self.wasm_channels_dir, + ) + .await?; Ok(InstallResult { name: name.to_string(), @@ -1482,6 +1500,7 @@ impl ExtensionManager { async fn download_and_install_wasm( &self, name: &str, + archive_crate_name: Option<&str>, url: &str, capabilities_url: Option<&str>, target_dir: &std::path::Path, @@ -1557,7 +1576,7 @@ impl ExtensionManager { // Detect format: gzip (tar.gz bundle) or bare WASM if bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b { // tar.gz bundle: extract {name}.wasm and {name}.capabilities.json - self.extract_wasm_tar_gz(name, &bytes, &wasm_path, &caps_path)?; + self.extract_wasm_tar_gz(name, archive_crate_name, &bytes, &wasm_path, &caps_path)?; } else { // Bare WASM file: validate magic number if bytes.len() < 4 || &bytes[..4] != b"\0asm" { @@ -1621,6 +1640,7 @@ impl ExtensionManager { fn extract_wasm_tar_gz( &self, name: &str, + archive_crate_name: Option<&str>, bytes: &[u8], target_wasm: &std::path::Path, target_caps: &std::path::Path, @@ -1640,8 +1660,9 @@ impl ExtensionManager { // 100 MB cap on decompressed entry size to prevent decompression bombs const MAX_ENTRY_SIZE: u64 = 100 * 1024 * 1024; - let wasm_filename = format!("{}.wasm", name); - let caps_filename = format!("{}.capabilities.json", name); + let wasm_filenames = archive_filename_candidates(name, archive_crate_name, ".wasm"); + let caps_filenames = + archive_filename_candidates(name, archive_crate_name, ".capabilities.json"); let mut found_wasm = false; let entries = archive @@ -1672,14 +1693,14 @@ impl ExtensionManager { .and_then(|n| n.to_str()) .unwrap_or(""); - if filename == wasm_filename { + if wasm_filenames.iter().any(|candidate| candidate == filename) { let mut data = Vec::with_capacity(entry.size() as usize); std::io::Read::read_to_end(&mut entry.by_ref().take(MAX_ENTRY_SIZE), &mut data) .map_err(|e| ExtensionError::InstallFailed(e.to_string()))?; std::fs::write(target_wasm, &data) .map_err(|e| ExtensionError::InstallFailed(e.to_string()))?; found_wasm = true; - } else if filename == caps_filename { + } else if caps_filenames.iter().any(|candidate| candidate == filename) { let mut data = Vec::with_capacity(entry.size() as usize); std::io::Read::read_to_end(&mut entry.by_ref().take(MAX_ENTRY_SIZE), &mut data) .map_err(|e| ExtensionError::InstallFailed(e.to_string()))?; @@ -1690,8 +1711,8 @@ impl ExtensionManager { if !found_wasm { return Err(ExtensionError::InstallFailed(format!( - "tar.gz archive does not contain '{}'", - wasm_filename + "tar.gz archive does not contain any of: {}", + wasm_filenames.join(", ") ))); } @@ -4152,6 +4173,50 @@ impl ExtensionManager { } } +fn archive_filename_candidates( + extension_name: &str, + archive_crate_name: Option<&str>, + suffix: &str, +) -> Vec { + let mut candidates = Vec::new(); + + for base in [Some(extension_name), archive_crate_name] + .into_iter() + .flatten() + { + let raw = format!("{}{}", base, suffix); + if !candidates.contains(&raw) { + candidates.push(raw); + } + + let snake = format!("{}{}", base.replace('-', "_"), suffix); + if !candidates.contains(&snake) { + candidates.push(snake); + } + } + + candidates +} + +fn preferred_archive_crate_name<'a>( + entry: &'a crate::extensions::RegistryEntry, + source: &'a ExtensionSource, +) -> Option<&'a str> { + match source { + ExtensionSource::WasmBuildable { crate_name, .. } => crate_name.as_deref(), + ExtensionSource::WasmDownload { .. } => { + entry + .fallback_source + .as_deref() + .and_then(|fallback| match fallback { + ExtensionSource::WasmBuildable { crate_name, .. } => crate_name.as_deref(), + _ => None, + }) + } + _ => None, + } +} + /// Inject credentials for a channel based on naming convention. /// /// Looks for secrets matching the pattern `{channel_name}_*` and injects them @@ -4339,6 +4404,10 @@ fn combine_install_errors( mod tests { use std::sync::Arc; + use flate2::Compression; + use flate2::write::GzEncoder; + use tar::Builder; + use crate::extensions::ExtensionManager; use crate::extensions::manager::{ FallbackDecision, combine_install_errors, fallback_decision, infer_kind_from_url, @@ -4451,6 +4520,59 @@ mod tests { ); } + fn build_test_archive(wasm_name: &str, caps_name: Option<&str>) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + { + let mut builder = Builder::new(&mut encoder); + + let wasm_data = b"\0asm\x01\0\0\0"; + let mut wasm_header = tar::Header::new_gnu(); + wasm_header.set_size(wasm_data.len() as u64); + wasm_header.set_cksum(); + builder + .append_data(&mut wasm_header, wasm_name, &wasm_data[..]) + .expect("append wasm"); + + if let Some(caps_name) = caps_name { + let caps_data = br#"{"auth":null}"#; + let mut caps_header = tar::Header::new_gnu(); + caps_header.set_size(caps_data.len() as u64); + caps_header.set_cksum(); + builder + .append_data(&mut caps_header, caps_name, &caps_data[..]) + .expect("append caps"); + } + + builder.finish().expect("finish archive"); + } + + encoder.finish().expect("finish gzip") + } + + #[test] + fn test_extract_wasm_tar_gz_accepts_crate_named_bundle() { + let dir = tempfile::tempdir().expect("temp dir"); + let mgr = make_test_manager(None, dir.path().to_path_buf()); + let archive = build_test_archive( + "telegram_tool.wasm", + Some("telegram-tool.capabilities.json"), + ); + let wasm_path = dir.path().join("telegram-mtproto.wasm"); + let caps_path = dir.path().join("telegram-mtproto.capabilities.json"); + + mgr.extract_wasm_tar_gz( + "telegram-mtproto", + Some("telegram-tool"), + &archive, + &wasm_path, + &caps_path, + ) + .expect("crate-named archive should install"); + + assert!(wasm_path.exists()); + assert!(caps_path.exists()); + } + // === QA Plan P2 - 2.4: Extension registry collision tests (filesystem) === #[test] diff --git a/src/llm/mod.rs b/src/llm/mod.rs index b49e4974a1..f4d47c2d95 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -330,7 +330,8 @@ fn create_ollama_from_registry( ); let adapter = RigAdapter::new(model, &config.model) - .with_unsupported_params(config.unsupported_params.clone()); + .with_unsupported_params(config.unsupported_params.clone()) + .with_ollama_base_url(Some(config.base_url.clone())); Ok(Arc::new(adapter)) } diff --git a/src/llm/rig_adapter.rs b/src/llm/rig_adapter.rs index 41724c319e..a3dc132932 100644 --- a/src/llm/rig_adapter.rs +++ b/src/llm/rig_adapter.rs @@ -46,6 +46,9 @@ pub struct RigAdapter { /// Parameter names that this provider does not support (e.g., `"temperature"`). /// These are stripped from requests before sending to avoid 400 errors. unsupported_params: HashSet, + /// Ollama base URL for model listing (e.g., http://localhost:11434). + /// When set, list_models() will fetch available models from /api/tags. + ollama_base_url: Option, } impl RigAdapter { @@ -61,6 +64,7 @@ impl RigAdapter { output_cost, cache_retention: CacheRetention::None, unsupported_params: HashSet::new(), + ollama_base_url: None, } } @@ -99,6 +103,23 @@ impl RigAdapter { self } + /// Set the Ollama base URL for model listing. + /// + /// When set, `list_models()` will fetch available models from the Ollama + /// `/api/tags` endpoint. + pub fn with_ollama_base_url(mut self, url: impl Into>) -> Self { + self.ollama_base_url = url.into(); + self + } + + fn ollama_model_list_fallback(&self) -> Vec { + if self.model_name.is_empty() { + Vec::new() + } else { + vec![self.model_name.clone()] + } + } + /// Strip unsupported fields from a `CompletionRequest` in place. fn strip_unsupported_completion_params(&self, req: &mut CompletionRequest) { strip_unsupported_completion_params(&self.unsupported_params, req); @@ -563,6 +584,82 @@ where } } + async fn list_models(&self) -> Result, LlmError> { + // Only Ollama currently supports model listing via HTTP API + let base_url = match &self.ollama_base_url { + Some(url) => url, + None => return Ok(vec![]), // Other providers: no model listing support + }; + + // Fetch /api/tags from Ollama + let client = reqwest::Client::new(); + let tags_url = format!("{}/api/tags", base_url.trim_end_matches('/')); + + let response = match client + .get(&tags_url) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + { + Ok(response) => response, + Err(error) => { + tracing::warn!( + model = %self.model_name, + base_url, + error = %error, + "Failed to fetch Ollama model tags; falling back to configured model" + ); + return Ok(self.ollama_model_list_fallback()); + } + }; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + tracing::warn!( + model = %self.model_name, + base_url, + %status, + body, + "Ollama /api/tags returned an error; falling back to configured model" + ); + return Ok(self.ollama_model_list_fallback()); + } + + let body = match response.json::().await { + Ok(body) => body, + Err(error) => { + tracing::warn!( + model = %self.model_name, + base_url, + error = %error, + "Failed to parse Ollama model tags; falling back to configured model" + ); + return Ok(self.ollama_model_list_fallback()); + } + }; + + // Extract model names from models array + let models: Vec = body + .get("models") + .and_then(|m| m.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|item| item.get("name").and_then(|n| n.as_str()).map(String::from)) + .collect() + }) + .unwrap_or_default(); + + if models.is_empty() { + tracing::debug!( + "Ollama /api/tags returned no models; falling back to configured model" + ); + Ok(self.ollama_model_list_fallback()) + } else { + Ok(models) + } + } + async fn complete( &self, mut request: CompletionRequest, @@ -1280,4 +1377,24 @@ mod tests { assert!(adapter.unsupported_params.is_empty()); } + + #[tokio::test] + async fn test_list_models_falls_back_to_configured_model_on_request_failure() { + use rig::client::CompletionClient; + use rig::providers::openai; + + let client: openai::Client = openai::Client::builder() + .api_key("test-key") + .base_url("http://localhost:0") + .build() + .unwrap(); + let client = client.completions_api(); + let model = client.completion_model("qwen3.5:9b-q8_0"); + let adapter = RigAdapter::new(model, "qwen3.5:9b-q8_0") + .with_ollama_base_url(Some("http://127.0.0.1:0".to_string())); + + let models = adapter.list_models().await.unwrap(); + + assert_eq!(models, vec!["qwen3.5:9b-q8_0".to_string()]); + } } diff --git a/src/registry/installer.rs b/src/registry/installer.rs index 91f536f4c6..d5ea56c65c 100644 --- a/src/registry/installer.rs +++ b/src/registry/installer.rs @@ -419,8 +419,15 @@ impl RegistryInstaller { // Detect format and extract let has_capabilities = if is_gzip(&bytes) { // tar.gz bundle: extract {name}.wasm and {name}.capabilities.json - let extracted = - extract_tar_gz(&bytes, &manifest.name, &target_wasm, &target_caps, url)?; + let extracted = extract_tar_gz( + &bytes, + &manifest.name, + Some(&manifest.source.crate_name), + Some(&manifest.source.capabilities), + &target_wasm, + &target_caps, + url, + )?; extracted.has_capabilities } else { // Bare WASM file @@ -638,6 +645,8 @@ struct ExtractResult { fn extract_tar_gz( bytes: &[u8], name: &str, + archive_crate_name: Option<&str>, + archive_caps_name: Option<&str>, target_wasm: &Path, target_caps: &Path, url: &str, @@ -657,8 +666,8 @@ fn extract_tar_gz( // 100 MB cap on decompressed entry size to prevent decompression bombs const MAX_ENTRY_SIZE: u64 = 100 * 1024 * 1024; - let wasm_filename = format!("{}.wasm", name); - let caps_filename = format!("{}.capabilities.json", name); + let wasm_filenames = archive_filename_candidates(name, archive_crate_name, ".wasm"); + let caps_filenames = archive_capability_candidates(name, archive_crate_name, archive_caps_name); let mut found_wasm = false; let mut found_caps = false; @@ -700,21 +709,24 @@ fn extract_tar_gz( .and_then(|n| n.to_str()) .unwrap_or(""); - if filename == wasm_filename { + if wasm_filenames.iter().any(|candidate| candidate == filename) { let mut data = Vec::with_capacity(entry.size() as usize); std::io::Read::read_to_end(&mut entry.by_ref().take(MAX_ENTRY_SIZE), &mut data) .map_err(|e| RegistryError::DownloadFailed { url: url.to_string(), - reason: format!("failed to read {} from archive: {}", wasm_filename, e), + reason: format!("failed to read matching wasm entry from archive: {}", e), })?; std::fs::write(target_wasm, &data).map_err(RegistryError::Io)?; found_wasm = true; - } else if filename == caps_filename { + } else if caps_filenames.iter().any(|candidate| candidate == filename) { let mut data = Vec::with_capacity(entry.size() as usize); std::io::Read::read_to_end(&mut entry.by_ref().take(MAX_ENTRY_SIZE), &mut data) .map_err(|e| RegistryError::DownloadFailed { url: url.to_string(), - reason: format!("failed to read {} from archive: {}", caps_filename, e), + reason: format!( + "failed to read matching capabilities entry from archive: {}", + e + ), })?; std::fs::write(target_caps, &data).map_err(RegistryError::Io)?; found_caps = true; @@ -725,8 +737,8 @@ fn extract_tar_gz( return Err(RegistryError::DownloadFailed { url: url.to_string(), reason: format!( - "tar.gz archive does not contain '{}'. Archive may be malformed.", - wasm_filename + "tar.gz archive does not contain any of: {}. Archive may be malformed.", + wasm_filenames.join(", ") ), }); } @@ -736,6 +748,53 @@ fn extract_tar_gz( }) } +fn archive_filename_candidates( + extension_name: &str, + archive_crate_name: Option<&str>, + suffix: &str, +) -> Vec { + let mut candidates = Vec::new(); + + for base in [Some(extension_name), archive_crate_name] + .into_iter() + .flatten() + { + let raw = format!("{}{}", base, suffix); + if !candidates.contains(&raw) { + candidates.push(raw); + } + + let snake = format!("{}{}", base.replace('-', "_"), suffix); + if !candidates.contains(&snake) { + candidates.push(snake); + } + } + + candidates +} + +fn archive_capability_candidates( + extension_name: &str, + archive_crate_name: Option<&str>, + archive_caps_name: Option<&str>, +) -> Vec { + let mut candidates = + archive_filename_candidates(extension_name, archive_crate_name, ".capabilities.json"); + + if let Some(caps_name) = archive_caps_name { + let caps_name = std::path::Path::new(caps_name) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(caps_name) + .to_string(); + if !candidates.contains(&caps_name) { + candidates.push(caps_name); + } + } + + candidates +} + #[cfg(test)] mod tests { use super::*; @@ -987,8 +1046,16 @@ mod tests { let wasm_path = tmp.path().join("test.wasm"); let caps_path = tmp.path().join("test.capabilities.json"); - let result = - extract_tar_gz(&gz_bytes, "test", &wasm_path, &caps_path, "test://url").unwrap(); + let result = extract_tar_gz( + &gz_bytes, + "test", + None, + None, + &wasm_path, + &caps_path, + "test://url", + ) + .unwrap(); assert!(wasm_path.exists()); assert!(caps_path.exists()); @@ -1080,6 +1147,8 @@ mod tests { let result = extract_tar_gz( &gz_bytes, "test", + None, + None, &tmp.path().join("test.wasm"), &tmp.path().join("test.capabilities.json"), "test://url", @@ -1162,6 +1231,8 @@ mod tests { let result = extract_tar_gz( &gz_bytes, "slack-tool", + None, + None, &tmp.path().join("slack-tool.wasm"), &tmp.path().join("slack-tool.capabilities.json"), "test://url", @@ -1192,6 +1263,8 @@ mod tests { let result = extract_tar_gz( &gz_bytes, "slack-tool", + None, + None, &wasm_path, &caps_path, "test://url", @@ -1212,8 +1285,43 @@ mod tests { let wasm_path = tmp.path().join("slack.wasm"); let caps_path = tmp.path().join("slack.capabilities.json"); - let result = - extract_tar_gz(&gz_bytes, "slack", &wasm_path, &caps_path, "test://url").unwrap(); + let result = extract_tar_gz( + &gz_bytes, + "slack", + None, + None, + &wasm_path, + &caps_path, + "test://url", + ) + .unwrap(); + + assert!(wasm_path.exists()); + assert!(caps_path.exists()); + assert!(result.has_capabilities); + } + + #[test] + fn test_extract_tar_gz_accepts_crate_named_entries() { + let gz_bytes = build_test_tar_gz( + "telegram_tool.wasm", + Some("telegram-tool.capabilities.json"), + ); + + let tmp = tempfile::tempdir().unwrap(); + let wasm_path = tmp.path().join("telegram-mtproto.wasm"); + let caps_path = tmp.path().join("telegram-mtproto.capabilities.json"); + + let result = extract_tar_gz( + &gz_bytes, + "telegram-mtproto", + Some("telegram-tool"), + Some("telegram-tool.capabilities.json"), + &wasm_path, + &caps_path, + "test://url", + ) + .unwrap(); assert!(wasm_path.exists()); assert!(caps_path.exists());