From fa2b77230bf0e700cd07c57c5b0c6969d2434083 Mon Sep 17 00:00:00 2001 From: Spacehunterz <201493392+Spacehunterz@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:05:12 -0600 Subject: [PATCH] feat(ollama): add auto-discovery with Anthropic API and security hardening - Auto-detect Ollama without requiring API key configuration - Switch to Anthropic Messages API for better Claude compatibility - Add security checks to prevent network exposure (0.0.0.0 binding) - Add desktop launcher with security validation - Hardcode localhost-only connections (127.0.0.1:11434) Security features: - Launch script forces OLLAMA_HOST=127.0.0.1:11434 - Warns if Ollama is exposed to network interfaces - Checks .bashrc/.profile for dangerous OLLAMA_HOST settings - ollama-security.ts module for URL validation New files: - scripts/launch-clawdbot-ollama.sh - Secure launcher script - scripts/install-desktop-launcher.sh - Desktop entry installer - assets/clawdbot-ollama.desktop - Linux desktop entry - src/agents/ollama-security.ts - Security utilities Co-Authored-By: Claude Opus 4.5 --- assets/clawdbot-ollama.desktop | 10 + scripts/install-desktop-launcher.sh | 50 +++++ scripts/launch-clawdbot-ollama.sh | 188 ++++++++++++++++++ .../models-config.providers.ollama.test.ts | 20 +- src/agents/models-config.providers.ts | 22 +- src/agents/ollama-security.test.ts | 31 +++ src/agents/ollama-security.ts | 117 +++++++++++ 7 files changed, 428 insertions(+), 10 deletions(-) create mode 100644 assets/clawdbot-ollama.desktop create mode 100755 scripts/install-desktop-launcher.sh create mode 100755 scripts/launch-clawdbot-ollama.sh create mode 100644 src/agents/ollama-security.test.ts create mode 100644 src/agents/ollama-security.ts diff --git a/assets/clawdbot-ollama.desktop b/assets/clawdbot-ollama.desktop new file mode 100644 index 000000000..bad2aa52b --- /dev/null +++ b/assets/clawdbot-ollama.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Name=Clawdbot (Ollama) +Comment=AI Assistant with local Ollama models +Exec=/home/eve/Desktop/clawdbotPLUS/scripts/launch-clawdbot-ollama.sh +Icon=/home/eve/Desktop/clawdbotPLUS/assets/chrome-extension/icons/icon128.png +Terminal=true +Type=Application +Categories=Development;Utility; +Keywords=ai;chat;ollama;local;llm; +StartupNotify=true diff --git a/scripts/install-desktop-launcher.sh b/scripts/install-desktop-launcher.sh new file mode 100755 index 000000000..215ad929e --- /dev/null +++ b/scripts/install-desktop-launcher.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Install Clawdbot Ollama desktop launcher + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +# Paths +DESKTOP_FILE="$PROJECT_DIR/assets/clawdbot-ollama.desktop" +LAUNCH_SCRIPT="$PROJECT_DIR/scripts/launch-clawdbot-ollama.sh" +ICON_FILE="$PROJECT_DIR/assets/chrome-extension/icons/icon128.png" +DEST_DIR="$HOME/.local/share/applications" + +# Create destination directory if it doesn't exist +mkdir -p "$DEST_DIR" + +# Make launch script executable +chmod +x "$LAUNCH_SCRIPT" + +# Create the desktop file with absolute paths +cat > "$DEST_DIR/clawdbot-ollama.desktop" << EOF +[Desktop Entry] +Name=Clawdbot (Ollama) +Comment=AI Assistant with local Ollama models +Exec=$LAUNCH_SCRIPT +Icon=$ICON_FILE +Terminal=true +Type=Application +Categories=Development;Utility; +Keywords=ai;chat;ollama;local;llm; +StartupNotify=true +EOF + +# Make desktop file executable (required on some distros) +chmod +x "$DEST_DIR/clawdbot-ollama.desktop" + +# Update desktop database +if command -v update-desktop-database &> /dev/null; then + update-desktop-database "$DEST_DIR" 2>/dev/null || true +fi + +echo "Desktop launcher installed!" +echo "Location: $DEST_DIR/clawdbot-ollama.desktop" +echo "" +echo "You can now find 'Clawdbot (Ollama)' in your application menu." +echo "" +echo "To also add it to your desktop:" +echo " cp $DEST_DIR/clawdbot-ollama.desktop ~/Desktop/" +echo " # Then right-click the icon and select 'Allow Launching'" diff --git a/scripts/launch-clawdbot-ollama.sh b/scripts/launch-clawdbot-ollama.sh new file mode 100755 index 000000000..169485ff7 --- /dev/null +++ b/scripts/launch-clawdbot-ollama.sh @@ -0,0 +1,188 @@ +#!/bin/bash +# Launch script for Clawdbot with Ollama +# Starts Ollama if not running, then launches Clawdbot +# +# SECURITY: This script enforces localhost-only Ollama connections + +set -e + +# ============================================================================= +# SECURITY CONFIGURATION +# ============================================================================= + +# Force Ollama to bind to localhost only (prevents network exposure) +export OLLAMA_HOST="127.0.0.1:11434" + +# Never allow these dangerous configurations +unset OLLAMA_ORIGINS # Prevents CORS bypass + +# ============================================================================= +# SECURITY CHECKS +# ============================================================================= + +check_ollama_security() { + echo "[Security] Checking Ollama configuration..." + + # Check if Ollama is listening on 0.0.0.0 (dangerous!) + if ss -tlnp 2>/dev/null | grep -q "0.0.0.0:11434"; then + echo "" + echo "!!! SECURITY WARNING !!!" + echo "Ollama is exposed to ALL network interfaces (0.0.0.0:11434)" + echo "This allows anyone on your network to use your Ollama instance!" + echo "" + echo "To fix this:" + echo " 1. Stop Ollama: systemctl --user stop ollama (or pkill ollama)" + echo " 2. Edit config: Remove OLLAMA_HOST=0.0.0.0 from your environment" + echo " 3. Restart Ollama (this script will bind it to localhost)" + echo "" + read -p "Continue anyway? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborted for security reasons." + exit 1 + fi + fi + + # Check if Ollama is listening on [::] (IPv6 all interfaces - also dangerous) + if ss -tlnp 2>/dev/null | grep -q "\[::\]:11434"; then + echo "" + echo "!!! SECURITY WARNING !!!" + echo "Ollama is exposed on IPv6 all interfaces ([::]:11434)" + echo "" + read -p "Continue anyway? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborted for security reasons." + exit 1 + fi + fi + + # Check for dangerous environment variables in user's environment + if [ -f "$HOME/.bashrc" ] && grep -q "OLLAMA_HOST=0.0.0.0" "$HOME/.bashrc" 2>/dev/null; then + echo "" + echo "[Warning] Found OLLAMA_HOST=0.0.0.0 in ~/.bashrc" + echo "Consider removing this to prevent network exposure." + echo "" + fi + + if [ -f "$HOME/.profile" ] && grep -q "OLLAMA_HOST=0.0.0.0" "$HOME/.profile" 2>/dev/null; then + echo "" + echo "[Warning] Found OLLAMA_HOST=0.0.0.0 in ~/.profile" + echo "Consider removing this to prevent network exposure." + echo "" + fi + + # Check systemd service if it exists + if [ -f "/etc/systemd/system/ollama.service" ]; then + if grep -q "OLLAMA_HOST=0.0.0.0" /etc/systemd/system/ollama.service 2>/dev/null; then + echo "" + echo "[Warning] System Ollama service is configured to bind to 0.0.0.0" + echo "Edit /etc/systemd/system/ollama.service to fix this." + echo "" + fi + fi + + echo "[Security] Checks complete." +} + +check_firewall() { + # Informational: Check if firewall is active + if command -v ufw &> /dev/null; then + if ufw status 2>/dev/null | grep -q "Status: active"; then + echo "[Security] UFW firewall is active (good!)" + else + echo "[Info] UFW firewall is not active. Consider enabling it:" + echo " sudo ufw enable" + fi + elif command -v firewall-cmd &> /dev/null; then + if firewall-cmd --state 2>/dev/null | grep -q "running"; then + echo "[Security] Firewalld is active (good!)" + fi + fi +} + +# ============================================================================= +# MAIN SCRIPT +# ============================================================================= + +echo "=== Clawdbot + Ollama Launcher ===" +echo "" + +# Run security checks +check_ollama_security +check_firewall + +# Check if Ollama is running +if ! pgrep -x "ollama" > /dev/null; then + echo "" + echo "Starting Ollama (localhost only)..." + + # Start Ollama with secure settings + ollama serve & + OLLAMA_PID=$! + + # Wait for Ollama to be ready + echo "Waiting for Ollama to start..." + for i in {1..30}; do + if curl -s http://127.0.0.1:11434/api/tags > /dev/null 2>&1; then + echo "Ollama is ready (PID: $OLLAMA_PID)" + break + fi + if [ $i -eq 30 ]; then + echo "Error: Ollama failed to start within 30 seconds" + exit 1 + fi + sleep 1 + done +else + echo "Ollama is already running" +fi + +# Verify we're connecting to localhost only +echo "" +echo "[Security] Connecting to: http://127.0.0.1:11434 (localhost only)" + +# Check if any models are available +MODELS_JSON=$(curl -s http://127.0.0.1:11434/api/tags 2>/dev/null) +MODEL_COUNT=$(echo "$MODELS_JSON" | grep -o '"name"' | wc -l) + +if [ "$MODEL_COUNT" -eq 0 ]; then + echo "" + echo "No Ollama models found. You need to pull a model first." + echo "" + echo "Recommended models for coding:" + echo " ollama pull qwen3-coder # Fast, good for code" + echo " ollama pull deepseek-r1:8b # Reasoning model" + echo " ollama pull llama3.3 # General purpose" + echo "" + + # Open terminal to let user pull a model + if command -v gnome-terminal &> /dev/null; then + gnome-terminal -- bash -c "echo 'Pull a model with: ollama pull '; echo ''; ollama list; exec bash" + elif command -v konsole &> /dev/null; then + konsole -e bash -c "echo 'Pull a model with: ollama pull '; echo ''; ollama list; exec bash" + elif command -v xterm &> /dev/null; then + xterm -e "echo 'Pull a model with: ollama pull '; ollama list; exec bash" + fi + exit 1 +fi + +echo "Found $MODEL_COUNT Ollama model(s)" +echo "" +echo "Starting Clawdbot..." +echo "" + +# Launch Clawdbot +if command -v clawdbot &> /dev/null; then + exec clawdbot +elif [ -f "$HOME/.local/bin/clawdbot" ]; then + exec "$HOME/.local/bin/clawdbot" +elif [ -f "/usr/local/bin/clawdbot" ]; then + exec /usr/local/bin/clawdbot +else + # Run from source + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + cd "$PROJECT_DIR" + exec pnpm clawdbot +fi diff --git a/src/agents/models-config.providers.ollama.test.ts b/src/agents/models-config.providers.ollama.test.ts index 0dab1b22c..3902dda0f 100644 --- a/src/agents/models-config.providers.ollama.test.ts +++ b/src/agents/models-config.providers.ollama.test.ts @@ -5,11 +5,27 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; describe("Ollama provider", () => { - it("should not include ollama when no API key is configured", async () => { + it("should not include ollama when no models are discovered", async () => { const agentDir = mkdtempSync(join(tmpdir(), "clawd-test-")); const providers = await resolveImplicitProviders({ agentDir }); - // Ollama requires explicit configuration via OLLAMA_API_KEY env var or profile + // Ollama auto-discovers models; if none found, provider is not included + // In test environment, discovery is skipped so no models are found expect(providers?.ollama).toBeUndefined(); }); + + it("should use anthropic-messages API when Ollama is configured", async () => { + // This test verifies the provider config structure + // Actual model discovery requires a running Ollama instance + const agentDir = mkdtempSync(join(tmpdir(), "clawd-test-")); + const providers = await resolveImplicitProviders({ agentDir }); + + // When Ollama is discovered, it should use anthropic-messages API + // (In test env, no models are discovered so provider won't be present) + if (providers?.ollama) { + expect(providers.ollama.api).toBe("anthropic-messages"); + expect(providers.ollama.baseUrl).toBe("http://127.0.0.1:11434"); + expect(providers.ollama.apiKey).toBe("ollama"); + } + }); }); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 996f09dd0..92c4bf7d8 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -64,7 +64,14 @@ const QWEN_PORTAL_DEFAULT_COST = { cacheWrite: 0, }; -const OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1"; +// Ollama supports both OpenAI-compatible (/v1) and Anthropic Messages API +// We use the Anthropic Messages API for better Claude compatibility +// +// SECURITY: Hardcoded to 127.0.0.1 (localhost) only. +// Ollama has no built-in authentication - anyone who can reach the port can use it. +// Never change these to 0.0.0.0 or expose Ollama to the network without a reverse +// proxy that handles authentication (e.g., nginx with basic auth or OAuth). +const OLLAMA_BASE_URL = "http://127.0.0.1:11434"; const OLLAMA_API_BASE_URL = "http://127.0.0.1:11434"; const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000; const OLLAMA_DEFAULT_MAX_TOKENS = 8192; @@ -354,7 +361,7 @@ async function buildOllamaProvider(): Promise { const models = await discoverOllamaModels(); return { baseUrl: OLLAMA_BASE_URL, - api: "openai-completions", + api: "anthropic-messages", models, }; } @@ -410,12 +417,11 @@ export async function resolveImplicitProviders(params: { }; } - // Ollama provider - only add if explicitly configured - const ollamaKey = - resolveEnvApiKeyVarName("ollama") ?? - resolveApiKeyFromProfiles({ provider: "ollama", store: authStore }); - if (ollamaKey) { - providers.ollama = { ...(await buildOllamaProvider()), apiKey: ollamaKey }; + // Ollama provider - auto-detect if running locally, no API key needed + // Ollama ignores the API key but we set a placeholder for compatibility + const ollamaProvider = await buildOllamaProvider(); + if (ollamaProvider.models.length > 0) { + providers.ollama = { ...ollamaProvider, apiKey: "ollama" }; } return providers; diff --git a/src/agents/ollama-security.test.ts b/src/agents/ollama-security.test.ts new file mode 100644 index 000000000..5662d8961 --- /dev/null +++ b/src/agents/ollama-security.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { isOllamaUrlSafe, getSafeOllamaUrl } from "./ollama-security.js"; + +describe("Ollama Security", () => { + describe("isOllamaUrlSafe", () => { + it("should allow localhost URLs", () => { + expect(isOllamaUrlSafe("http://localhost:11434")).toBe(true); + expect(isOllamaUrlSafe("http://127.0.0.1:11434")).toBe(true); + expect(isOllamaUrlSafe("http://[::1]:11434")).toBe(true); + }); + + it("should reject non-localhost URLs", () => { + expect(isOllamaUrlSafe("http://0.0.0.0:11434")).toBe(false); + expect(isOllamaUrlSafe("http://192.168.1.100:11434")).toBe(false); + expect(isOllamaUrlSafe("http://10.0.0.1:11434")).toBe(false); + expect(isOllamaUrlSafe("http://ollama.example.com:11434")).toBe(false); + expect(isOllamaUrlSafe("http://my-server:11434")).toBe(false); + }); + + it("should reject invalid URLs", () => { + expect(isOllamaUrlSafe("not-a-url")).toBe(false); + expect(isOllamaUrlSafe("")).toBe(false); + }); + }); + + describe("getSafeOllamaUrl", () => { + it("should always return localhost URL", () => { + expect(getSafeOllamaUrl()).toBe("http://127.0.0.1:11434"); + }); + }); +}); diff --git a/src/agents/ollama-security.ts b/src/agents/ollama-security.ts new file mode 100644 index 000000000..3f618e4fa --- /dev/null +++ b/src/agents/ollama-security.ts @@ -0,0 +1,117 @@ +/** + * Ollama Security Utilities + * + * Ollama has NO built-in authentication. Anyone who can reach the Ollama port + * can use your models, see your prompts, and consume your compute resources. + * + * NEVER expose Ollama to the network (0.0.0.0) without a reverse proxy that + * handles authentication. + */ + +import { exec } from "node:child_process"; +import { promisify } from "node:util"; + +const execAsync = promisify(exec); + +export interface OllamaSecurityStatus { + isRunning: boolean; + isExposedToNetwork: boolean; + bindAddress: string | null; + warnings: string[]; +} + +/** + * Check if Ollama is running and whether it's securely configured. + * Returns warnings if Ollama is exposed to the network. + */ +export async function checkOllamaSecurity(): Promise { + const status: OllamaSecurityStatus = { + isRunning: false, + isExposedToNetwork: false, + bindAddress: null, + warnings: [], + }; + + try { + // Check listening sockets for Ollama port (11434) + const { stdout } = await execAsync("ss -tlnp 2>/dev/null | grep 11434 || true"); + + if (!stdout.trim()) { + // Ollama not running or not listening + return status; + } + + status.isRunning = true; + + // Parse the bind address + // Example output: "LISTEN 0 4096 127.0.0.1:11434 0.0.0.0:*" + // Or dangerous: "LISTEN 0 4096 0.0.0.0:11434 0.0.0.0:*" + const lines = stdout.trim().split("\n"); + + for (const line of lines) { + // Check for dangerous bindings + if (line.includes("0.0.0.0:11434")) { + status.isExposedToNetwork = true; + status.bindAddress = "0.0.0.0:11434"; + status.warnings.push( + "Ollama is listening on 0.0.0.0:11434 (ALL network interfaces)", + "Anyone on your network can access your Ollama instance", + "This is a security risk - Ollama has no authentication", + "Fix: Set OLLAMA_HOST=127.0.0.1:11434 and restart Ollama", + ); + } else if (line.includes("[::]:11434")) { + status.isExposedToNetwork = true; + status.bindAddress = "[::]:11434"; + status.warnings.push( + "Ollama is listening on [::]:11434 (ALL IPv6 interfaces)", + "Anyone on your network can access your Ollama instance", + "Fix: Set OLLAMA_HOST=127.0.0.1:11434 and restart Ollama", + ); + } else if (line.includes("127.0.0.1:11434")) { + status.bindAddress = "127.0.0.1:11434"; + // This is secure - localhost only + } + } + } catch { + // ss command not available or failed - can't determine status + status.warnings.push("Could not verify Ollama network binding (ss command unavailable)"); + } + + return status; +} + +/** + * Validate that an Ollama URL is localhost-only. + * Returns true if the URL is safe, false if it points to a non-local address. + */ +export function isOllamaUrlSafe(url: string): boolean { + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + + // Safe hostnames + const safeHosts = ["localhost", "127.0.0.1", "::1", "[::1]"]; + + if (safeHosts.includes(hostname)) { + return true; + } + + // Check for localhost IPv6 variants + if (hostname.startsWith("[") && hostname.includes("::1")) { + return true; + } + + return false; + } catch { + return false; + } +} + +/** + * Get a safe Ollama URL, always pointing to localhost. + * Ignores any environment variables that might point elsewhere. + */ +export function getSafeOllamaUrl(): string { + // Always return localhost - never trust environment variables for this + return "http://127.0.0.1:11434"; +}