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 <noreply@anthropic.com>
This commit is contained in:
Spacehunterz 2026-01-26 14:05:12 -06:00
parent 10d5ea5de6
commit fa2b77230b
7 changed files with 428 additions and 10 deletions

View File

@ -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

View File

@ -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'"

188
scripts/launch-clawdbot-ollama.sh Executable file
View File

@ -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 <model-name>'; echo ''; ollama list; exec bash"
elif command -v konsole &> /dev/null; then
konsole -e bash -c "echo 'Pull a model with: ollama pull <model-name>'; echo ''; ollama list; exec bash"
elif command -v xterm &> /dev/null; then
xterm -e "echo 'Pull a model with: ollama pull <model-name>'; 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

View File

@ -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");
}
});
});

View File

@ -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<ProviderConfig> {
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;

View File

@ -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");
});
});
});

View File

@ -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<OllamaSecurityStatus> {
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";
}