dockroot-mcp-source / discover.py
jpanasuk's picture
v2.0: synced MCP fixes (tool listing quote bug, key preservation)
fb7dd66
Raw History Blame Contribute Delete
107 kB
#!/usr/bin/env python3
"""
Basecamp Discovery β€” scans Docker network for AI services and auto-generates tools.
No hardcoded URLs or keys. It finds what's running and builds the toolkit dynamically.
Usage:
python3 discover.py scan|status # scan network, print discovered services
python3 discover.py serve # run the connect screen TUI (first-run setup)
python3 discover.py config # generate basecamp_config.json
python3 discover.py tools # generate the tavern CLI script
python3 discover.py env # write Hermes runtime env file from saved config
python3 discover.py wire # audit stack wiring + print fixes
python3 discover.py self-check # verify every service's health (exit 1 on RED)
python3 discover.py update-check # poll registries for newer image tags
python3 discover.py rediscover # re-scan + regenerate everything
Most users never call this directly β€” use the `basecamp` launcher and the
`tavern` toolkit it generates (basecamp tavern status / chat / models / ...).
"""
import json
import os
import sys
import socket
import subprocess
import base64
import threading
import concurrent.futures
import urllib.request
import urllib.error
from pathlib import Path
CONFIG_FILE = Path(os.environ.get("BASECAMP_CONFIG", "/root/.hermes/basecamp_config.json"))
DISCOVERY_FILE = Path(os.environ.get("BASECAMP_DISCOVERY", "/root/.hermes/discovery.json"))
# ── Service fingerprints ──
# Each probe has: paths, method, optional headers, match function, parse function
SERVICE_PROBES = {
"ollama": {
"paths": ["/api/tags"],
"method": "GET",
"match": lambda r: '"models"' in r,
"parse": lambda r: {
"models": [m["name"] for m in json.loads(r).get("models", [])]
},
"label": "Ollama (GGUF inference)",
"icon": "LLM",
},
"tabbyapi": {
# FIX v2.0: probe TabbyAPI's OWN endpoint (/v1/internal/model/list)
# FIRST β€” only TabbyAPI serves it, so a match there is specific
# (vLLM/LiteLLM/LocalAI on 5000 answer /v1/models identically and
# were being mislabeled tabbyapi). Fall back to /v1/models: some
# TabbyAPI builds don't expose the internal route (VERIFIED: 404),
# but /v1/models returns 401 {"detail":"Invalid API key"} which
# match_auth catches.
"paths": ["/v1/internal/model/list", "/v1/models"],
"method": "GET",
"match": lambda r: '"data"' in r and ('"id"' in r or '"model"' in r),
"match_auth": lambda r: '"detail"' in r and ("api key" in r.lower() or "not authenticated" in r.lower() or "unauthorized" in r.lower()),
"parse": lambda r: {
"models": [m["id"] for m in json.loads(r).get("data", [])]
},
"label": "TabbyAPI (EXL3/EXL2 GPU inference)",
"icon": "EXL",
"needs_auth": True,
"auth_type": "bearer",
},
"vllm": {
"paths": ["/v1/models"],
"method": "GET",
"match": lambda r: '"vllm"' in r.lower() or ('"object":"list"' in r and '"data"' in r),
"parse": lambda r: {
"models": [m["id"] for m in json.loads(r).get("data", [])]
},
"label": "vLLM (high-throughput inference)",
"icon": "LLM",
},
"litellm": {
"paths": ["/v1/models", "/health"],
"method": "GET",
"match": lambda r: '"data"' in r and ('"model"' in r.lower() or "litellm" in r.lower()),
"parse": lambda r: {
"models": [m.get("id", m.get("name", "")) for m in json.loads(r).get("data", [])]
},
"label": "LiteLLM Proxy (multi-provider router)",
"icon": "PXY",
"needs_auth": True,
"auth_type": "bearer",
},
"localai": {
"paths": ["/v1/models", "/models"],
"method": "GET",
"match": lambda r: '"data"' in r and '"id"' in r,
"parse": lambda r: {
"models": [m.get("id", m.get("name", "")) for m in json.loads(r).get("data", [])]
},
"label": "LocalAI (drop-in OpenAI replacement)",
"icon": "LLM",
},
"llamacpp": {
"paths": ["/health", "/v1/models"],
"method": "GET",
"match": lambda r: '"models"' in r or r.strip() == "ok",
"parse": lambda r: {},
"label": "llama.cpp server",
"icon": "LLM",
},
"text-generation-webui": {
"paths": ["/api/v1/model"],
"method": "GET",
"match": lambda r: '"model_name"' in r or '"result"' in r,
"parse": lambda r: {},
"label": "Text Generation WebUI (oobabooga)",
"icon": "TGW",
},
"open-webui": {
"paths": ["/", "/api/config"],
"method": "GET",
"match": lambda r: "open-webui" in r.lower() or "Open WebUI" in r,
"parse": lambda r: {},
"label": "Open WebUI (LLM workspace)",
"icon": "UI ",
},
"sillytavern": {
"paths": ["/api/status"],
"method": "GET",
"match": lambda r: "unauthorized" in r.lower() or "sillytavern" in r.lower() or '"jinja"' in r.lower() or '"result"' in r.lower(),
"match_auth": lambda r: "unauthorized" in r.lower() or "401" in r or "basic realm" in r.lower(),
"parse": lambda r: {},
"label": "SillyTavern (character chat)",
"icon": "CHT",
"needs_auth": True,
"auth_type": "basic",
},
"searxng": {
"paths": ["/", "/search?q=test&format=json"],
"method": "GET",
"match": lambda r: "searxng" in r.lower(),
"parse": lambda r: {},
"label": "SearXNG (private search)",
"icon": "SRC",
},
"mcpo": {
"paths": ["/openapi.json"],
"method": "GET",
"match": lambda r: '"MCP OpenAPI Proxy"' in r,
"parse": lambda r: {},
"label": "MCPO (MCP OpenAPI proxy)",
"icon": "MCP",
"needs_auth": True,
"auth_type": "bearer",
},
# ── More inference engines ──
"koboldcpp": {
# FIX (overhaul): the old match ('"result"' or '"data"') matched ANY
# OpenAI-compatible response β€” on port 5001 it mislabeled llamafile.
# Require a koboldcpp-specific marker (its /api/v1/model returns the
# loaded model name in "result"), plus the generic /v1/models shape.
"paths": ["/api/v1/model", "/v1/models"],
"method": "GET",
"match": lambda r: ('"result"' in r and ("model" in r.lower() or "kobold" in r.lower()))
or ('"data"' in r and '"id"' in r and "kobold" in r.lower()),
"parse": lambda r: {
"models": [m["id"] for m in json.loads(r).get("data", [])]
if '"data"' in r else []
},
"label": "KoboldCpp (GGML inference)",
"icon": "KOB",
},
"lmstudio": {
"paths": ["/v1/models"],
"method": "GET",
"match": lambda r: '"data"' in r and '"id"' in r and "lmstudio" not in r.lower(),
"parse": lambda r: {
"models": [m["id"] for m in json.loads(r).get("data", [])]
},
"label": "LM Studio (local model server)",
"icon": "LMS",
},
"sglang": {
"paths": ["/v1/models"],
"method": "GET",
"match": lambda r: '"sglang"' in r.lower() or ('"object":"list"' in r and '"data"' in r and "vllm" not in r.lower()),
"parse": lambda r: {
"models": [m["id"] for m in json.loads(r).get("data", [])]
},
"label": "SGLang (fast LLM serving)",
"icon": "SGL",
},
"llamafile": {
"paths": ["/v1/models", "/health"],
"method": "GET",
"match": lambda r: '"data"' in r or '"status"' in r,
"parse": lambda r: {},
"label": "llamafile (single-file LLM)",
"icon": "LMF",
},
"exo": {
"paths": ["/v1/models"],
"method": "GET",
"match": lambda r: '"data"' in r and '"id"' in r,
"parse": lambda r: {},
"label": "exo (distributed inference)",
"icon": "EXO",
},
"text-generation-inference": {
"paths": ["/v1/models", "/info"],
"method": "GET",
"match": lambda r: '"model_id"' in r or '"models"' in r,
"parse": lambda r: {},
"label": "TGI (Text Generation Inference)",
"icon": "TGI",
},
"aphrodite": {
"paths": ["/v1/models"],
"method": "GET",
"match": lambda r: '"aphrodite"' in r.lower(),
"parse": lambda r: {},
"label": "Aphrodite Engine (inference)",
"icon": "APH",
},
"llamapool": {
"paths": ["/v1/models"],
"method": "GET",
"match": lambda r: '"llama-pool"' in r.lower() or "llama pool" in r.lower(),
"parse": lambda r: {},
"label": "llama-pool (model router)",
"icon": "PL",
},
# ── More UIs / chat frontends ──
"librechat": {
"paths": ["/", "/api/health"],
"method": "GET",
"match": lambda r: "librechat" in r.lower() or "LibreChat" in r,
"parse": lambda r: {},
"label": "LibreChat (multi-model chat UI)",
"icon": "UI ",
},
"lobechat": {
"paths": ["/", "/api/status"],
"method": "GET",
"match": lambda r: "lobechat" in r.lower() or "LobeChat" in r,
"parse": lambda r: {},
"label": "LobeChat (AI chat framework)",
"icon": "UI ",
},
"anythingllm": {
"paths": ["/", "/api/system/endpoints"],
"method": "GET",
"match": lambda r: "anythingllm" in r.lower() or "AnythingLLM" in r,
"parse": lambda r: {},
"label": "AnythingLLM (RAG workspace)",
"icon": "RAG",
},
"dify": {
"paths": ["/", "/health"],
"method": "GET",
"match": lambda r: "dify" in r.lower() or "Dify" in r,
"parse": lambda r: {},
"label": "Dify (LLM app platform)",
"icon": "APP",
},
"flowise": {
"paths": ["/", "/api/v1/ping"],
"method": "GET",
"match": lambda r: "flowise" in r.lower() or "Flowise" in r,
"parse": lambda r: {},
"label": "Flowise (no-code agent builder)",
"icon": "FLW",
},
"n8n": {
"paths": ["/", "/healthz"],
"method": "GET",
"match": lambda r: "n8n" in r.lower() or "N8N" in r or "workflow" in r.lower(),
"parse": lambda r: {},
"label": "n8n (workflow automation)",
"icon": "WRK",
},
"langflow": {
"paths": ["/", "/api/v1/configs"],
"method": "GET",
"match": lambda r: "langflow" in r.lower() or "Langflow" in r,
"parse": lambda r: {},
"label": "Langflow (agent builder)",
"icon": "LGF",
},
"ragflow": {
"paths": ["/", "/api/v1/version"],
"method": "GET",
"match": lambda r: "ragflow" in r.lower() or "RAGFlow" in r,
"parse": lambda r: {},
"label": "RAGFlow (RAG engine)",
"icon": "RAG",
},
"koboldai": {
"paths": ["/", "/api/v1/config/status"],
"method": "GET",
"match": lambda r: "koboldai" in r.lower() or "KoboldAI" in r,
"parse": lambda r: {},
"label": "KoboldAI (writing assistant UI)",
"icon": "CHT",
},
# ── Vector DBs (RAG backends) ──
"qdrant": {
"paths": ["/", "/readyz"],
"method": "GET",
"match": lambda r: "qdrant" in r.lower() or "Qdrant" in r,
"parse": lambda r: {},
"label": "Qdrant (vector DB)",
"icon": "VDB",
},
"milvus": {
"paths": ["/healthz", "/api/v1/health"],
"method": "GET",
"match": lambda r: "milvus" in r.lower() or "Milvus" in r,
"parse": lambda r: {},
"label": "Milvus (vector DB)",
"icon": "VDB",
},
"chroma": {
# FIX (overhaul): the old match ('"200"' or '"ok"') matched nearly
# EVERYTHING β€” on port 8000 it could mislabel sillytavern's
# {"result":"ok"}. Chroma's /api/v2/heartbeat returns a bare epoch
# int; match THAT or the explicit "heartbeat" word only.
"paths": ["/api/v2/heartbeat", "/api/v2"],
"method": "GET",
"match": lambda r: r.strip().lstrip('"').lstrip("'").isdigit()
or "heartbeat" in r.lower(),
"parse": lambda r: {},
"label": "Chroma (vector DB)",
"icon": "VDB",
},
"weaviate": {
"paths": ["/v1/meta"],
"method": "GET",
"match": lambda r: '"version"' in r and ("weaviate" in r.lower() or '"model"' in r),
"parse": lambda r: {},
"label": "Weaviate (vector DB)",
"icon": "VDB",
},
# ── Image / media generation ──
"comfyui": {
"paths": ["/", "/system_stats"],
"method": "GET",
"match": lambda r: "comfyui" in r.lower() or "ComfyUI" in r or "Comfy" in r,
"parse": lambda r: {},
"label": "ComfyUI (diffusion workflows)",
"icon": "IMG",
},
"stable-diffusion-webui": {
"paths": ["/", "/sdapi/v1/options"],
"method": "GET",
"match": lambda r: "stable diffusion" in r.lower() or "gradio" in r.lower() or '"sd_model_checkpoint"' in r,
"parse": lambda r: {},
"label": "Stable Diffusion WebUI (A1111)",
"icon": "IMG",
},
"invokeai": {
"paths": ["/", "/api/v1/app/version"],
"method": "GET",
"match": lambda r: "invokeai" in r.lower() or "InvokeAI" in r,
"parse": lambda r: {},
"label": "InvokeAI (image generation)",
"icon": "IMG",
},
# ── Speech / audio ──
"whisper": {
"paths": ["/", "/health"],
"method": "GET",
"match": lambda r: "whisper" in r.lower() or "Whisper" in r,
"parse": lambda r: {},
"label": "Whisper (speech-to-text)",
"icon": "STT",
},
"piper": {
"paths": ["/", "/health"],
"method": "GET",
"match": lambda r: "piper" in r.lower() or "Piper" in r,
"parse": lambda r: {},
"label": "Piper (text-to-speech)",
"icon": "TTS",
},
# ── Gateways / proxies / model hubs ──
# FIX (overhaul): openrouter probe REMOVED β€” it's a cloud API with no
# local port, so no candidate could ever reach it (dead probe). If a
# user runs an openrouter PROXY locally, litellm/vllm probes catch it.
"kobold-horde": {
"paths": ["/api/v1/status", "/"],
"method": "GET",
"match": lambda r: "kobold" in r.lower() or '"queued_requests"' in r,
"parse": lambda r: {},
"label": "KoboldAI Horde (crowd inference)",
"icon": "HDE",
},
# ── Coding starter pack ──
"code-server": {
"paths": ["/", "/healthz"],
"method": "GET",
"match": lambda r: "code-server" in r.lower() or "coder" in r.lower() or "vscode" in r.lower() or "404: Not Found" in r,
"parse": lambda r: {},
"label": "code-server (VS Code in browser)",
"icon": "IDE",
},
"tabby": {
"paths": ["/v1/health", "/api/health"],
"method": "GET",
"match": lambda r: '"health"' in r or '"model"' in r or "tabby" in r.lower(),
"parse": lambda r: {},
"label": "Tabby (AI code completion)",
"icon": "CPL",
},
"meilisearch": {
"paths": ["/health", "/"],
"method": "GET",
"match": lambda r: '"status":"available"' in r or "meilisearch" in r.lower(),
"parse": lambda r: {},
"label": "Meilisearch (full-text search)",
"icon": "SRH",
},
"mongo": {
"paths": ["/"],
"method": "GET",
"match": lambda r: "mongodb" in r.lower() or "mongo" in r.lower() or "It looks like you are trying to access MongoDB over HTTP" in r,
"parse": lambda r: {},
"label": "MongoDB (database)",
"icon": "DB ",
},
}
# ── Port expectations ──
# Each port maps to a list of service probe names to try (in order).
PORT_SERVICE_MAP = {
11434: ["ollama"],
11435: ["ollama", "llamafile"],
11436: ["ollama"],
5000: ["vllm", "litellm", "localai", "llamacpp",
"text-generation-webui", "tabbyapi", "whisper", "piper", "aphrodite",
"llamapool"],
8000: ["sillytavern", "mcpo", "text-generation-inference", "chroma"],
8001: ["mcpo"],
8080: ["searxng", "open-webui"],
3000: ["open-webui", "dify", "flowise"],
5001: ["koboldcpp", "llamafile"],
1234: ["lmstudio"],
8002: ["sglang"],
4000: ["litellm", "exo"],
3001: ["lobechat", "dify"],
3080: ["librechat"],
7681: ["anythingllm"],
5678: ["n8n"],
9380: ["ragflow"],
7860: ["stable-diffusion-webui", "langflow"],
8188: ["comfyui"],
9090: ["invokeai", "qdrant"],
6333: ["qdrant"],
19530: ["milvus"],
8081: ["weaviate"],
1551: ["koboldai"],
2323: ["kobold-horde"],
8443: ["code-server"],
8082: ["tabby"],
7700: ["meilisearch"],
6334: ["qdrant"],
3210: ["lobechat"],
27017: ["mongo"],
5432: ["postgres"],
8005: ["chroma"],
}
COMMON_PORTS = [11434, 5000, 8000, 8080, 8001, 3000, 11435, 11436,
5001, 1234, 8002, 4000, 3001, 3080, 7681, 5678,
7860, 8188, 9090, 6333, 19530, 8081, 1551, 2323,
8443, 8082, 7700, 6334, 3210, 27017, 5432, 8005]
# ── HTTP helpers ──
def http_get(url, timeout=1, headers=None):
"""Simple HTTP GET returning response text or None.
For 401/403 responses, returns the error body (HTTPError bodies are
read and returned) so auth-gated services can still be fingerprinted
and listed as ``needs_auth`` β€” otherwise a service behind a 401 wall
(TabbyAPI, SillyTavern, ...) is invisible to discovery.
"""
try:
req = urllib.request.Request(url, method="GET")
if headers:
for k, v in headers.items():
req.add_header(k, v)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
if e.code in (401, 403):
try:
return e.read().decode("utf-8", errors="replace")
except Exception:
return None
return None
except Exception:
return None
def http_post(url, data=None, headers=None, timeout=30):
"""Simple HTTP POST returning the response body (or None on error).
FIX v2.0: removed the dead stream=True generator branch, and no longer
mutates the caller's headers dict (copies before setdefault)."""
try:
body = json.dumps(data).encode() if data else b""
hdrs = dict(headers or {})
hdrs.setdefault("Content-Type", "application/json")
req = urllib.request.Request(url, data=body, headers=hdrs, method="POST")
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode("utf-8", errors="replace")
except Exception:
return None
def host_reachable(host, port, timeout=0.5):
"""Quick TCP connect check."""
try:
ip = socket.gethostbyname(host)
except Exception:
return False
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
result = sock.connect_ex((ip, port))
sock.close()
return result == 0
except Exception:
return False
# ── Network scanner ──
def scan_network():
"""Scan Docker network for AI services. Returns list of discovered services."""
discovered = []
candidates = [] # (name, host, port, network)
# 1. Docker network inspect (if docker CLI available)
try:
result = subprocess.run(
["docker", "network", "ls", "--format", "{{.Name}}"],
capture_output=True, text=True, timeout=5
)
networks = result.stdout.strip().split("\n") if result.stdout.strip() else []
except Exception:
networks = []
for net in networks:
if not net:
continue
try:
result = subprocess.run(
["docker", "network", "inspect", net, "--format",
"{{range .Containers}}{{.Name}} {{.IPv4Address}}{{println}}{{end}}"],
capture_output=True, text=True, timeout=5
)
for line in result.stdout.strip().split("\n"):
if not line.strip():
continue
parts = line.strip().split()
if len(parts) >= 2:
name = parts[0]
ip = parts[1].split("/")[0] if "/" in parts[1] else parts[1]
for port in COMMON_PORTS:
candidates.append((name, ip, port, net))
except Exception:
pass
# 2. Common Docker DNS service names
common_dns_names = [
"tabbyapi", "ollama", "open-webui", "sillytavern", "searxng", "mcpo",
"vllm", "litellm", "localai", "llamacpp",
# coding starter pack containers (docker-compose.starter.yml names)
"starter-code-server", "starter-tabby", "starter-qdrant", "starter-chroma",
"starter-n8n", "starter-lobe-chat", "starter-anythingllm", "starter-librechat",
"starter-meilisearch", "starter-flowise", "starter-postgres", "starter-mongo",
"code-server", "qdrant", "chroma", "n8n", "lobe-chat", "anythingllm",
"librechat", "meilisearch", "flowise", "postgres", "mongo",
]
# Container-INTERNAL ports (the compose host:container mapping means the
# container listens on the INTERNAL port, not the host-mapped one).
# E.g. code-server maps 8443:8080 -> the container listens on 8080.
CONTAINER_PORT_MAP = {
"starter-code-server": 8080, "code-server": 8080,
"starter-chroma": 8000, "chroma": 8000,
"starter-flowise": 3000, "flowise": 3000,
"starter-tabby": 8080, "tabby": 8080,
"starter-librechat": 3080, "librechat": 3080,
"starter-meilisearch": 7700, "meilisearch": 7700,
"starter-postgres": 5432, "postgres": 5432,
"starter-mongo": 27017, "mongo": 27017,
"starter-anythingllm": 3001, "anythingllm": 3001,
"starter-qdrant": 6333, "qdrant": 6333,
"starter-n8n": 5678, "n8n": 5678,
"starter-lobe-chat": 3210, "lobe-chat": 3210,
}
for name in common_dns_names:
# Use the container-internal port when known; else the classic single
# port for that service (NOT all 32 COMMON_PORTS β€” that explodes the
# candidate count and starves the scan timeout).
if name in CONTAINER_PORT_MAP:
ports = [CONTAINER_PORT_MAP[name]]
else:
classic = {
"tabbyapi": 5000, "ollama": 11434, "open-webui": 8080,
"sillytavern": 8000, "searxng": 8080, "mcpo": 8000,
"vllm": 8000, "litellm": 4000, "localai": 8080, "llamacpp": 8080,
}
ports = [classic.get(name, 8080)]
for port in ports:
candidates.append((name, name, port, "dns"))
# 3. Localhost
for port in COMMON_PORTS:
candidates.append(("localhost", "127.0.0.1", port, "local"))
# 4. Subnet sweep β€” ADAPTIVE discovery. When the docker socket isn't
# mounted (no `docker network inspect`), the box must still find
# services on ANY network it lands on, with ANY container names.
# Strategy: first find LIVE hosts with a fast ping-scan of the /24,
# then probe only live hosts for the well-known AI ports. This avoids
# the 254x14 candidate explosion.
if not networks: # only when docker CLI/socket unavailable
# Moderate widening (v2.0): original 14 + flagship ports the old set
# missed (mcpo 8001, code-server 8443, librechat 3080, litellm 4000,
# lmstudio 1234, sglang 8002, anythingllm 7681). NOT all 32
# COMMON_PORTS β€” that explodes candidates and starves the 30s
# as_completed window (verified regression).
sweep_ports = {11434, 5000, 8000, 8080, 3000, 3001, 11435,
6333, 5678, 3210, 7700, 27017, 5432, 8005,
8001, 8443, 3080, 4000, 1234, 8002, 7681}
try:
local_ip = socket.gethostbyname(socket.gethostname())
subnet = ".".join(local_ip.split(".")[:3]) # /24
except Exception:
subnet = None
if subnet:
live_hosts = []
def _ping_one(octet):
host = f"{subnet}.{octet}"
return host if any(
host_reachable(host, p, timeout=0.15)
for p in (11434, 8000, 8080, 3000, 5432)
) else None
with concurrent.futures.ThreadPoolExecutor(max_workers=64) as ex:
for result in ex.map(_ping_one, range(1, 255)):
if result:
live_hosts.append(result)
# Probe only live hosts (skip self, which localhost covers)
for host in live_hosts:
if host == local_ip:
continue
for port in sweep_ports:
candidates.append((host, host, port, "sweep"))
# 5. Cross-host / remote hosts β€” BASECAMP_EXTRA_HOSTS env (comma or
# space separated host[:port] list). Lets the box reach services on
# other machines (e.g. a GPU server across the LAN): each host gets
# the common ports probed.
extra = os.environ.get("BASECAMP_EXTRA_HOSTS", "").strip()
if extra:
for entry in extra.replace(",", " ").split():
entry = entry.strip()
if not entry:
continue
if ":" in entry:
host, _, port_s = entry.rpartition(":")
try:
ports = [int(port_s)]
except ValueError:
ports = COMMON_PORTS
else:
host, ports = entry, COMMON_PORTS
for port in ports:
candidates.append((host, host, port, "extra"))
# Deduplicate β€” collapse starter-NAME and NAME (same container, same IP)
# FIX (UX overhaul 2026-08-11): the old loop called socket.gethostbyname
# on EVERY candidate with NO timeout β€” a slow resolver (or a host with
# no A record, e.g. removed pack services starter-tabby/starter-flowise)
# hung the whole scan indefinitely (watchdog-verified: blocked at
# gethostbyname 20s+). Two-part fix:
# (a) skip resolution for candidates that are already IPs (sweep,
# local, extra, docker-inspect all carry IPs β€” the common case);
# (b) for DNS names, resolve in a daemon thread and join with a hard
# 1s timeout β€” setdefaulttimeout does NOT bound gethostbyname
# (the C resolver ignores it), only a thread join can.
import ipaddress as _ipaddr
def _resolve_bounded(host, timeout=1.0):
out = [host]
def _do():
try:
out[0] = socket.gethostbyname(host)
except Exception:
pass
t = threading.Thread(target=_do, daemon=True)
t.start()
t.join(timeout)
return out[0]
seen = set()
unique = []
for name, host, port, net in candidates:
key_host = host
try:
_ipaddr.ip_address(host) # raises if not a plain IP
except ValueError:
# host is a DNS name β€” resolve with a hard bounded timeout
key_host = _resolve_bounded(host)
key = f"{key_host}:{port}"
if key not in seen:
seen.add(key)
unique.append((name, host, port, net))
# Probe in parallel
def probe_host(args):
name, host, port, net = args
if not host_reachable(host, port, timeout=0.5):
return None
base_url = f"http://{host}:{port}"
# Postgres speaks a raw TCP protocol, not HTTP β€” sniff the version
# banner (SSLRequest β†’ server replies with version bytes) instead.
if port == 5432:
try:
with socket.create_connection((host, port), timeout=2) as sock:
# SSLRequest (8-byte magic) β€” postgres answers with a
# single byte: 'N' (no SSL) or 'S' (SSL supported).
# FIX v2.0: validate that byte β€” any TCP service replying
# to the magic was being mislabeled postgres.
sock.sendall(b"\x00\x00\x00\x08\x04\xd2\x16\x2f")
banner = sock.recv(256)
if banner and banner[0:1] in (b"N", b"S"):
return {
"type": "postgres",
"label": "PostgreSQL (memory DB, pgvector)",
"icon": "DB ",
"host": name,
"url": base_url,
"port": port,
"network": net,
"details": {"banner": f"{len(banner)} bytes pg greeting"},
"needs_auth": False,
"auth_type": "none",
}
except Exception:
pass
return None
# FIX 2026-08-10 (v2.0): mongo is TCP-only β€” an HTTP probe always
# fails, so starter-mongo was never discovered. Sniff the wire
# protocol instead: send an OP_QUERY isMaster (opcode 2004) and
# treat ANY reply bytes as a live mongo.
if port == 27017:
try:
with socket.create_connection((host, port), timeout=2) as sock:
# OP_MSG (opcode 2013) with {isMaster: 1} body β€” the
# MODERN mongo wire handshake. Legacy OP_QUERY (2004)
# is not answered by mongo 5+/7 (VERIFIED: times out).
bson = (
b"\x13\x00\x00\x00" # BSON len 19
b"\x10isMaster\x00" # int32 field
b"\x01\x00\x00\x00" # value 1
b"\x00" # BSON terminator
)
op_msg = (
b"\x28\x00\x00\x00" # msgLength 40
b"\x01\x00\x00\x00" # requestID
b"\x00\x00\x00\x00" # responseTo
b"\xdd\x07\x00\x00" # opCode 2013 (OP_MSG)
b"\x00\x00\x00\x00" # flagBits
b"\x00" # section kind 0 (body)
+ bson
)
sock.sendall(op_msg)
reply = sock.recv(512)
if reply:
return {
"type": "mongo",
"label": "MongoDB (database)",
"icon": "DB ",
"host": name,
"url": base_url,
"port": port,
"network": net,
"details": {"banner": f"{len(reply)} bytes mongo reply"},
"needs_auth": False,
"auth_type": "none",
}
except Exception:
pass
return None
# FIX v2.0: unknown-port fallback no longer probes ALL ~40 service
# types (~80s per candidate, blew the 30s scan window). Cap to a
# small generic OpenAI-compatible + ollama set.
service_types = PORT_SERVICE_MAP.get(port, ["ollama", "tabbyapi", "vllm", "litellm", "localai"])
# For DNS-name candidates with a known container-internal port, also
# probe the service type implied by the name (e.g. starter-code-server
# on 8080 would otherwise only be probed as searxng/open-webui).
if net == "dns":
for key, cport in CONTAINER_PORT_MAP.items():
if key == name and cport == port:
# derive type: strip starter- prefix, match probe names
base_type = name.replace("starter-", "")
for probe_type in SERVICE_PROBES:
if base_type == probe_type or base_type in probe_type:
if probe_type not in service_types:
service_types = list(service_types) + [probe_type]
break
break
for svc_type in service_types:
probe = SERVICE_PROBES.get(svc_type)
if not probe:
continue
for path in probe["paths"]:
url = f"{base_url}{path}"
# Try without auth first
resp = http_get(url, timeout=1)
if resp and probe["match"](resp):
try:
details = probe["parse"](resp)
except Exception:
details = {}
return {
"type": svc_type,
"label": probe["label"],
"icon": probe["icon"],
"host": name,
"url": base_url,
"port": port,
"network": net,
"details": details,
"needs_auth": probe.get("needs_auth", False),
"auth_type": probe.get("auth_type", "bearer"),
}
# Auth-gated service: the 401/403 body still identifies it
# (e.g. TabbyAPI's {"detail":"Please provide an API key"}).
# List it as needs_auth so the connect screen can prompt for a key.
if (
probe.get("match_auth")
and resp
and probe["match_auth"](resp)
):
return {
"type": svc_type,
"label": probe["label"],
"icon": probe["icon"],
"host": name,
"url": base_url,
"port": port,
"network": net,
"details": {},
"needs_auth": True,
"auth_type": probe.get("auth_type", "bearer"),
}
# If service needs auth and we got nothing, try with common defaults
if probe.get("needs_auth") and not resp:
auth_headers = get_default_auth_headers(probe.get("auth_type", "bearer"))
if auth_headers:
resp = http_get(url, timeout=1, headers=auth_headers)
if resp and probe["match"](resp):
try:
details = probe["parse"](resp)
except Exception:
details = {}
return {
"type": svc_type,
"label": probe["label"],
"icon": probe["icon"],
"host": name,
"url": base_url,
"port": port,
"network": net,
"details": details,
"needs_auth": True,
"auth_type": probe.get("auth_type", "bearer"),
"auth_worked_with_default": True,
}
return None
executor = concurrent.futures.ThreadPoolExecutor(max_workers=50)
futures = {executor.submit(probe_host, c): c for c in unique}
try:
for future in concurrent.futures.as_completed(futures, timeout=30):
try:
result = future.result(timeout=2)
if result:
discovered.append(result)
except Exception:
pass
except concurrent.futures.TimeoutError:
# NB: on py<3.11, concurrent.futures.TimeoutError is NOT builtins.TimeoutError
pass # slow DNS/connect probes still running -- keep what we found
executor.shutdown(wait=False, cancel_futures=True)
# ── Global dedup (2026-08-10): the subnet sweep + gateway probing
# surfaces the SAME service multiple times β€” via its container name
# (open-webui:8080), via the host gateway (172.18.0.1:3000), via
# loopback. Every consumer (wire audit, tavern status, skill, menu)
# was seeing duplicates. Keep ONE entry per type, preferring the
# container-name host; drop gateway/loopback copies.
def _host_rank(s):
# Parse host robustly (IPv6 [::1] would otherwise split wrongly).
url = str(s.get("url", ""))
host = url.replace("http://", "").replace("https://", "")
if host.startswith("["):
host = host[1:host.find("]")] if "]" in host else host
else:
host = host.split(":")[0]
net = s.get("network", "")
if host.startswith("127.") or host == "localhost" or host == "::1" or net == "local":
return 3 # loopback = bundled copy
if host.startswith(("172.", "10.", "192.168.")):
# Private-IP hosts: gateway/host-mapped copies rank 2 β€” BUT a
# BASECAMP_EXTRA_HOSTS LAN server (net=extra) is a REAL remote
# host, rank it like a container name so it isn't deduped away.
return 2 if net != "extra" else 1
return 1 # container name / extra host β€” the best host
by_type = {}
for s in discovered:
t = s.get("type", "")
rank = _host_rank(s)
if t not in by_type or rank < by_type[t][1]:
by_type[t] = (s, rank)
deduped_services = []
for t, (s, rank) in by_type.items():
deduped_services.append(s)
discovered = deduped_services
return discovered
def _details_of(svc):
"""Safely return a service's details dict (may be missing/None/not-a-dict
in corrupt discovery files β€” never crash on it)."""
d = svc.get("details") if isinstance(svc, dict) else None
return d if isinstance(d, dict) else {}
def get_default_auth_headers(auth_type):
"""Return default auth headers to try during discovery (not stored permanently)."""
if auth_type == "basic":
# Try admin/tabby (SillyTavern default in our stack)
creds = base64.b64encode(b"admin:tabby").decode()
return {"Authorization": f"Basic {creds}"}
if auth_type == "bearer":
# No default bearer β€” must be provided by user
return None
return None
def save_discovery(services):
DISCOVERY_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(DISCOVERY_FILE, "w") as f:
json.dump(services, f, indent=2)
# ── Config generation ──
def generate_config(services, selected=None, auth_keys=None):
"""Generate basecamp_config.json from discovered services."""
config = {
"inference": None,
"secondary": None,
"search": None,
"mcp": None,
"ui": None,
"ollama_models": [],
"openai_models": [],
"api_keys": {},
}
auth_keys = auth_keys or {}
if selected:
for role, svc_idx in selected.items():
if svc_idx == -1:
# Sentinel from connect_screen: "use basecamp's own bundled
# Ollama" (option 0). Explicit β€” NOT auto-select.
if role in ("inference", "secondary"):
config[role] = {
"type": "ollama",
"url": "http://127.0.0.1:11434",
"label": "Basecamp bundled Ollama",
}
continue
# FIX (overhaul 2026-08-10): garbage/out-of-range selection
# crashed with TypeError (str vs int) or IndexError β€” a config
# must never blow up because a caller passed a bad index.
if not isinstance(svc_idx, int) or isinstance(svc_idx, bool):
continue
if svc_idx < 0 or svc_idx >= len(services):
continue
if svc_idx is not None and svc_idx < len(services):
svc = services[svc_idx]
# FIX (overhaul): a service dict without type/url (corrupt
# discovery file) must be skipped, not crash the whole config.
if not isinstance(svc, dict) or not svc.get("type") or not svc.get("url"):
continue
entry = {"type": svc["type"], "url": svc["url"], "label": svc.get("label", svc["type"])}
if svc.get("needs_auth"):
key = auth_keys.get(svc["url"], "")
if key:
entry["api_key"] = key
config["api_keys"][svc["url"]] = key
if role in ("inference", "secondary"):
config[role] = entry
if _details_of(svc).get("models"):
if svc["type"] == "ollama":
config["ollama_models"] = svc["details"]["models"]
else:
config["openai_models"] = svc["details"]["models"]
else:
config[role] = entry
else:
# Auto-select. Hermes hard-requires >=64K context (its system prompt
# alone is ~16K), so PREFER an Ollama endpoint as inference: Ollama
# models are typically served with a 64K+ window, whereas TabbyAPI /
# vLLM endpoints on small GPUs are often capped at 8K (exl2/exl3 KV
# cache limits) and would be rejected by hermes. OpenAI-compatible
# engines are still listed at the connect screen for interactive pick.
# FIX (overhaul 2026-08-10): tolerate corrupt service dicts (missing
# 'type'/'url') β€” a bad discovery file must not crash config gen.
def _valid(s):
return isinstance(s, dict) and s.get("type") and s.get("url")
good = [s for s in services if _valid(s)]
ollamas = [s for s in good if s["type"] == "ollama"]
openai_engines = [s for s in good if s["type"] in (
"tabbyapi", "vllm", "litellm", "localai", "llamacpp",
"text-generation-webui")]
if ollamas:
# Prefer an EXTERNAL ollama (the user's stack) over basecamp's own
# bundled one (127.0.0.1 / localhost) β€” the bundled ollama is the
# fallback for when nothing else exists. Discovery order is racy
# (parallel probes), so pick deterministically.
external = [s for s in ollamas
if not str(s["url"]).replace("http://", "").replace("https://", "").startswith(("127.", "localhost", "::1"))]
ordered = external + [s for s in ollamas if s not in external]
primary = ordered[0]
config["inference"] = {
"type": primary["type"], "url": primary["url"],
"label": primary.get("label", primary["type"])}
config["openai_models"] = []
# Exclude pre-existing basecamp/ aliases from the model list β€”
# they're created by write_basecamp_env, not user models.
_details = primary.get("details")
_details = _details if isinstance(_details, dict) else {}
config["ollama_models"] = [
m for m in _details.get("models", [])
if not str(m).startswith("basecamp/")
]
if len(ordered) > 1:
config["secondary"] = {"type": "ollama", "url": ordered[1]["url"], "label": ordered[1].get("label", "Ollama")}
elif openai_engines:
primary = openai_engines[0]
config["inference"] = {
"type": primary["type"], "url": primary["url"],
"label": primary["label"]}
config["openai_models"] = _details_of(primary).get("models", [])
for svc in good:
if svc["type"] == "searxng":
config["search"] = {"type": "searxng", "url": svc["url"], "label": svc.get("label", "SearXNG")}
elif svc["type"] == "mcpo":
config["mcp"] = {"type": "mcpo", "url": svc["url"], "label": svc.get("label", "MCPO")}
elif svc["type"] in ("open-webui", "sillytavern") and not config["ui"]:
config["ui"] = {"type": svc["type"], "url": svc["url"], "label": svc.get("label", svc["type"])}
# FIX (2026-08-11, user-reported): auth_keys were only applied inside
# the interactive `selected` path β€” auto-select (non-interactive boot)
# silently dropped saved MCP/API keys, so tavern mcp failed with
# "Missing or invalid Authorization header" after every headless boot.
# Merge keys here so BOTH paths preserve them.
for url, key in (auth_keys or {}).items():
if key and url:
config["api_keys"][url] = key
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=2)
try:
os.chmod(CONFIG_FILE, 0o600) # holds API keys -- lock it down
except Exception:
pass
return config
# ── Dynamic tavern script generation ──
def generate_tavern_script(config):
"""Generate a dynamic tavern CLI based on discovered config."""
# FIX (overhaul 2026-08-10): tolerate malformed configs β€” a caller bug
# (api_keys as str, inference missing label, etc.) must produce a usable
# script, never a crash. Values degrade to sane defaults.
if not isinstance(config, dict):
config = {}
api_keys = config.get("api_keys") or {}
if not isinstance(api_keys, dict):
api_keys = {}
inf = config.get("inference") or {}
sec = config.get("secondary") or {}
search = config.get("search") or {}
mcp = config.get("mcp") or {}
if not isinstance(inf, dict): inf = {}
if not isinstance(sec, dict): sec = {}
if not isinstance(search, dict): search = {}
if not isinstance(mcp, dict): mcp = {}
inf_url = inf.get("url", "") or ""
inf_type = inf.get("type", "") or ""
sec_url = sec.get("url", "") or ""
search_url = search.get("url", "") or ""
mcp_url = mcp.get("url", "") or ""
api_key = inf.get("api_key", "") or api_keys.get(inf_url, "")
# NEVER default to MCPO's well-known 'mcp-secret-key' β€” a key pressed
# through with Enter must not silently use a published default. Empty
# key = the generated tavern calls MCPO unauthenticated and the agent
# is told to configure one.
mcp_key = api_keys.get(mcp_url, "")
ollama_models = config.get("ollama_models", []) or []
openai_models = config.get("openai_models", []) or []
if not isinstance(ollama_models, list): ollama_models = []
if not isinstance(openai_models, list): openai_models = []
default_ollama = ollama_models[0] if ollama_models else "llama3.1:8b"
default_openai = openai_models[0] if openai_models else ""
script = f'''#!/bin/bash
# tavern β€” auto-generated by basecamp discovery
# Connected to: {inf.get("label", "none")}
CONFIG="{CONFIG_FILE}"
INFER_URL="{inf_url}"
INFER_TYPE="{inf_type}"
OLLAMA_URL="{sec_url}"
SEARCH_URL="{search_url}"
MCP_URL="{mcp_url}"
API_KEY="{api_key}"
MCP_KEY="{mcp_key}"
DEFAULT_OLLAMA_MODEL="{default_ollama}"
DEFAULT_OPENAI_MODEL="{default_openai}"
cmd="${{1:-help}}"
shift 2>/dev/null
case "$cmd" in
status)
echo "=== Basecamp Connected Services ==="
# FIX 2026-08-10: list EVERY discovered service (link:port), not
# just the five role slots. Reads the discovery file saved at boot.
if [ -f "{DISCOVERY_FILE}" ]; then
python3 -c "
import json
try:
svcs = json.load(open('{DISCOVERY_FILE}'))
except Exception:
svcs = []
for s in sorted(svcs, key=lambda x: x.get('type','')):
auth = ' (auth)' if s.get('needs_auth') else ''
print(' ' + s.get('icon',' ') + ' ' + (s.get('label','?')[:40]).ljust(40) + ' ' + s.get('url','?') + auth)
print(' -- ' + str(len(svcs)) + ' service(s) discovered --')
"
fi
'''
# FIX (UX overhaul): the role list echoed after the full service list
# with NO header β€” it looked like a formatting glitch. Label it.
roles = [(r, config.get(r)) for r in ("inference", "secondary", "search", "mcp", "ui")
if isinstance(config.get(r), dict) and config.get(r)]
if roles:
script += ' echo "=== Current wiring ===="\n'
for role, svc in roles:
icon = {"inference": "LLM", "secondary": "GGUF", "search": "SRC", "mcp": "MCP", "ui": "UI "}.get(role, " ")
label = str(svc.get("label", "?") or "?")[:40]
url = svc.get("url", "") or ""
script += f' echo " {icon} {label:40s} {url}"\n'
script += ''' ;;
models)
echo "=== Available Models ==="
'''
if openai_models:
for m in openai_models:
script += f' echo " OpenAI-compatible: {m}"\n'
if ollama_models:
for m in ollama_models:
script += f' echo " Ollama: {m}"\n'
if not openai_models and inf_url:
script += f' curl -s "{inf_url}/v1/models" -H "Authorization: Bearer $API_KEY" 2>/dev/null | python3 -c "import sys,json; [print(f\' OpenAI: {{m[\"id\"]}}\') for m in json.load(sys.stdin).get(\'data\',[])]" 2>/dev/null\n'
if not ollama_models and sec_url:
script += f' curl -s "{sec_url}/api/tags" 2>/dev/null | python3 -c "import sys,json; [print(f\' Ollama: {{m[\"name\"]}}\') for m in json.load(sys.stdin).get(\'models\',[])]" 2>/dev/null\n'
# Chat command with streaming
script += ''' ;;
chat)
MSG="${1:-Hello}"
MAX="${2:-256}"
STREAM="${3:-true}"
echo "=== Chat ==="
'''
if inf_type == "ollama":
script += f''' if [ "$STREAM" = "true" ]; then
PAYLOAD=$(jq -n --arg model "$DEFAULT_OLLAMA_MODEL" --arg content "$MSG" '{{model:$model, messages:[{{role:"user", content:$content}}], stream:true}}' 2>/dev/null)
curl -s "{inf_url}/api/chat" -H "Content-Type: application/json" \\
-d "$PAYLOAD" 2>/dev/null | \\
python3 -c "
import sys,json
for line in sys.stdin:
try:
d=json.loads(line)
c=d.get('message',{{}}).get('content','')
if c: print(c,end='',flush=True)
except: pass
print()
" 2>/dev/null
else
PAYLOAD=$(jq -n --arg model "$DEFAULT_OLLAMA_MODEL" --arg content "$MSG" '{{model:$model, messages:[{{role:"user", content:$content}}], stream:false}}' 2>/dev/null)
curl -s "{inf_url}/api/chat" -H "Content-Type: application/json" \\
-d "$PAYLOAD" 2>/dev/null | \\
python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('message',{{}}).get('content','(no response)'))" 2>/dev/null
fi
'''
elif inf_url:
model_ref = '$DEFAULT_OPENAI_MODEL' if default_openai else '$MODEL'
if not default_openai:
script += f''' MODEL=$(curl -s "{inf_url}/v1/models" -H "Authorization: Bearer $API_KEY" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['data'][0]['id'] if d.get('data') else '')" 2>/dev/null)
'''
else:
script += f' MODEL="$DEFAULT_OPENAI_MODEL"\n'
script += f''' echo "Model: $MODEL"
echo "You: $MSG"
echo -n "AI: "
if [ "$STREAM" = "true" ]; then
PAYLOAD=$(jq -n --arg model "$MODEL" --arg content "$MSG" --argjson max "$MAX" '{{model:$model, messages:[{{role:"user", content:$content}}], max_tokens:$max, stream:true}}' 2>/dev/null)
curl -s "{inf_url}/v1/chat/completions" -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \\
-d "$PAYLOAD" 2>/dev/null | \\
python3 -c "
import sys,json
for line in sys.stdin:
line=line.strip()
if not line or not line.startswith('data:'): continue
data=line[5:].strip()
if data=='[DONE]': break
try:
d=json.loads(data)
c=d['choices'][0].get('delta',{{}}).get('content','')
if c: print(c,end='',flush=True)
except: pass
print()
" 2>/dev/null
else
PAYLOAD=$(jq -n --arg model "$MODEL" --arg content "$MSG" --argjson max "$MAX" '{{model:$model, messages:[{{role:"user", content:$content}}], max_tokens:$max}}' 2>/dev/null)
curl -s "{inf_url}/v1/chat/completions" -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \\
-d "$PAYLOAD" 2>/dev/null | \\
python3 -c "import sys,json; d=json.load(sys.stdin); print(d['choices'][0]['message']['content'])" 2>/dev/null
fi
'''
# Search command
script += ''' ;;
search)
QUERY="${1:?Usage: tavern search \\"query\\"}"
NUM="${2:-5}"
echo "=== Search: $QUERY ==="
'''
if search_url:
script += f''' QENC=$(QUERY="$QUERY" python3 -c "import os,urllib.parse; print(urllib.parse.quote(os.environ['QUERY']))" 2>/dev/null)
curl -s "{search_url}/search?q=$QENC&format=json" 2>/dev/null | python3 -c "
import sys,json
d=json.load(sys.stdin)
for i,r in enumerate(d.get('results',[])[:$NUM],1):
print(f' {{i}}. {{r.get(\"title\",\"\")}}')
print(f' {{r.get(\"url\",\"\")}}')
print(f' {{r.get(\"content\",\"\")[:120]}}')
print()
" 2>/dev/null || echo " Search unavailable"
'''
# MCP command
script += ''' ;;
mcp)
if [ -z "$1" ]; then
echo "=== MCP Tools ==="
'''
if mcp_url:
script += f''' # Try server-prefixed paths
for prefix in "/host-master" ""; do
RESP=$(curl -s "{mcp_url}${{prefix}}/openapi.json" 2>/dev/null)
if echo "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('paths') else 1)" 2>/dev/null; then
echo "$RESP" | python3 -c "
import sys,json
d=json.load(sys.stdin)
for p,methods in d.get('paths',{{}}).items():
for m,info in methods.items():
print(' %-4s %s%s β€” %s' % (m.upper(), '{mcp_url}${{prefix}}', p, info.get('summary','')))
" 2>/dev/null
break
fi
done
'''
script += ''' else
TOOL="$1"; shift
BODY="${1:-{}}"
echo "=== MCP: $TOOL ==="
'''
if mcp_url:
script += f''' for prefix in "/host-master" ""; do
RESP=$(curl -s -X POST "{mcp_url}${{prefix}}/${{TOOL}}" -H "Authorization: Bearer $MCP_KEY" -H "Content-Type: application/json" -d "$BODY" 2>/dev/null)
if echo "$RESP" | python3 -c "import sys,json; json.load(sys.stdin)" 2>/dev/null; then
echo "$RESP" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin), indent=2))"
break
fi
done
'''
script += ''' fi
;;
config)
echo "=== Basecamp Configuration ==="
cat "$CONFIG" 2>/dev/null || echo " No config saved"
;;
rediscover)
echo "Re-running discovery..."
python3 /opt/basecamp/discover.py serve
;;
wire)
echo "Running stack wiring audit..."
python3 /opt/basecamp/discover.py wire
;;
self-check|selfcheck|doctor)
echo "Running self-check (verifying every service)..."
python3 /opt/basecamp/discover.py self-check
;;
update-check|updatecheck|updates)
echo "Checking registries for newer versions..."
python3 /opt/basecamp/discover.py update-check
;;
help|--help|-h)
echo "tavern β€” Basecamp AI stack toolkit (auto-generated)"
echo ""
echo "Commands:"
echo " status Show connected services"
echo " models List available models"
echo " chat \\\"msg\\\" [tokens] [stream] Chat with primary inference (stream=true/false)"
echo " search \\\"query\\\" [n] Search the web"
echo " mcp List MCP tools"
echo " mcp <tool> [json] Call an MCP tool"
echo " config Show saved configuration"
echo " wire Audit stack wiring + print fixes"
echo " self-check Verify every service (self-heal #1)"
echo " update-check Check registries for newer versions (self-heal #2)"
echo " rediscover Re-scan for services"
;;
*)
echo "Unknown: $cmd. Run: tavern help"
exit 1
;;
esac
'''
return script
# ── Connect screen TUI ──
def connect_screen(services):
"""Run the TUI connect screen for first-run service selection."""
print("\033[2J\033[H", end="")
print("=" * 60)
print(" BASECAMP β€” AI Agent + Local Inference")
print(" Connect Screen")
print("=" * 60)
print()
if not services:
print(" No AI services found on the network.")
print()
print(" Basecamp will run with its own Ollama instance.")
print(" You can re-discover later with: tavern rediscover")
print()
print(" Press Enter to continue...")
input()
return None, {}
print(f" Discovered {len(services)} service(s):")
print()
for i, svc in enumerate(services):
icon = svc["icon"]
label = svc["label"]
url = svc["url"]
models = _details_of(svc).get("models", [])
auth = " [needs auth]" if svc.get("needs_auth") else ""
model_str = f" [{len(models)} model(s)]" if models else ""
print(f" [{i:2d}] {icon} {label}{model_str}{auth}")
print(f" -> {url}")
print()
print("-" * 60)
print()
# Group by role
inference_opts = [(i, s) for i, s in enumerate(services)
if s["type"] in ("ollama", "tabbyapi", "vllm", "litellm", "localai", "llamacpp", "text-generation-webui")]
search_opts = [(i, s) for i, s in enumerate(services) if s["type"] == "searxng"]
mcp_opts = [(i, s) for i, s in enumerate(services) if s["type"] == "mcpo"]
selected = {}
auth_keys = {}
# Select inference β€” explained in plain language for new users.
if inference_opts:
# Dedupe: the subnet sweep + gateway probing surfaces the SAME
# engine multiple times β€” via its container name (tabbyapi:5000),
# via the host gateway (172.18.0.1:5000 = host-mapped port), and
# the bundled loopback ollama (127.0.0.1 β€” which is already
# option 0). Keep ONE entry per type, preferring the container
# name; drop gateway and loopback duplicates.
def _entry_rank(s):
host = s["url"].replace("http://", "").replace("https://", "").split(":")[0]
if host.startswith("127.") or host == "localhost":
return 3 # loopback = bundled (covered by option 0)
if host.startswith("172.") or host.startswith("10.") or host.startswith("192.168."):
return 2 # gateway/host-mapped view of a named container
return 1 # container name β€” the best way to reach it
best_by_type = {}
for i, s in inference_opts:
t = s["type"]
rank = _entry_rank(s)
if t not in best_by_type or rank < best_by_type[t][1]:
best_by_type[t] = (i, rank)
deduped = []
for i, s in inference_opts:
t = s["type"]
if best_by_type.get(t) and best_by_type[t][0] == i and best_by_type[t][1] < 3:
deduped.append((i, s))
# If a type only had gateway/loopback entries, keep the best one
for t, (i, rank) in best_by_type.items():
if rank >= 3:
if not any(s["type"] == t for _, s in deduped):
deduped.append((i, inference_opts[i][1]))
inference_opts = deduped
print(" PRIMARY inference engine β€” this is the 'brain' the agent uses.")
print(" Basecamp's own is the best starting choice: it comes with all")
print(" the fix recipes and stack knowledge pre-loaded.")
print()
print(" Suggested (recommended):")
print(" 0. Use basecamp's own Ollama (recommended)")
print(" - Built-in brain, no setup, everything pre-wired.")
print(" - Start here. You can switch anytime.")
print()
print(" Or pick one of the engines found on your network:")
for idx, (i, s) in enumerate(inference_opts):
friendly = {
"ollama": "Ollama β€” a local model server (free, runs on YOUR machine)",
"tabbyapi": "TabbyAPI β€” your GPU model server (EXL3/EXL2, fast)",
"vllm": "vLLM β€” high-throughput model server",
"litellm": "LiteLLM β€” proxy to many providers",
"localai": "LocalAI β€” local OpenAI-compatible server",
"llamacpp": "llama.cpp β€” local model server",
}.get(s["type"], s["type"])
print(f" {idx+1}. {s['label']} ({s['url']})")
print(f" {friendly}")
print()
print(" πŸ’‘ The one in the sky (cloud, e.g. Nous portal / OpenRouter) can")
print(" be added later in hermes settings β€” no need to pick it now.")
print(" You can change this choice ANYTIME by re-running:")
print(" basecamp rediscover")
print()
choice = input(" Choice [0]: ").strip() or "0"
try:
choice = int(choice)
if choice == 0:
# FIX v2.0: option 0 (bundled ollama) must be EXPLICIT β€”
# leaving selected["inference"] unset made generate_config
# auto-select an EXTERNAL engine instead (the opposite of
# what the user just chose). Sentinel: index -1 = bundled.
selected["inference"] = -1
elif 0 < choice <= len(inference_opts):
selected["inference"] = inference_opts[choice-1][0]
except ValueError:
# Invalid input: re-prompt instead of silently picking the
# first external engine (old behavior lied to the user).
selected["inference"] = -1
# Prompt for API key if needed
if selected.get("inference") is not None:
svc = services[selected["inference"]]
if svc.get("needs_auth"):
print()
print(f" {svc['label']} requires authentication ({svc.get('auth_type', 'bearer')}).")
key = input(" Enter API key (or press Enter to skip): ").strip()
if key:
auth_keys[svc["url"]] = key
# Secondary inference
if len(inference_opts) > 1 and selected.get("inference") is not None:
print()
print(" SECONDARY inference engine (optional) β€” a fallback brain")
print(" if the primary one ever goes down. 0 to skip.")
remaining = [(i, s) for i, s in inference_opts if i != selected["inference"]]
for idx, (i, s) in enumerate(remaining):
print(f" {idx+1}. {s['label']} ({s['url']})")
print(f" 0. Skip (recommended to start)")
choice = input(" Choice [0]: ").strip() or "0"
try:
choice = int(choice)
if 0 < choice <= len(remaining):
selected["secondary"] = remaining[choice-1][0]
except ValueError:
pass
else:
print(" No external inference engines found.")
print(" Basecamp will use its own Ollama.")
# Select search
if search_opts:
print()
print(" Private search found:")
for idx, (i, s) in enumerate(search_opts):
print(f" {idx+1}. {s['label']} ({s['url']})")
choice = input(" Use for search? [1]: ").strip() or "1"
try:
choice = int(choice)
if 0 < choice <= len(search_opts):
selected["search"] = search_opts[choice-1][0]
except ValueError:
selected["search"] = search_opts[0][0]
# Select MCP
if mcp_opts:
print()
print(" MCP tool server found:")
for idx, (i, s) in enumerate(mcp_opts):
print(f" {idx+1}. {s['label']} ({s['url']})")
choice = input(" Use MCP tools? [1]: ").strip() or "1"
try:
choice = int(choice)
if 0 < choice <= len(mcp_opts):
selected["mcp"] = mcp_opts[choice-1][0]
# Prompt for MCP key β€” NEVER suggest the well-known default
# 'mcp-secret-key' (that would silently bless a published
# secret). Empty key = unauthenticated; the agent will warn.
print()
mcp_key = input(" MCP API key (Enter to leave unauthenticated): ").strip()
if mcp_key:
auth_keys[services[mcp_opts[choice-1][0]]["url"]] = mcp_key
except ValueError:
selected["mcp"] = mcp_opts[0][0]
# FIX v2.0: UI role was never selectable β€” auto-select silently picked
# the first open-webui. Give the user the same choice as search/mcp.
ui_opts = [(i, s) for i, s in enumerate(services)
if s["type"] in ("open-webui", "sillytavern")]
if ui_opts:
print()
print(" Chat UI found (the web interface you'll actually use):")
for idx, (i, s) in enumerate(ui_opts):
print(f" {idx+1}. {s['label']} ({s['url']})")
print(" 0. Skip (no UI wiring)")
choice = input(f" Primary UI [1]: ").strip() or "1"
try:
choice = int(choice)
if 0 < choice <= len(ui_opts):
selected["ui"] = ui_opts[choice-1][0]
except ValueError:
selected["ui"] = ui_opts[0][0]
print()
print("-" * 60)
print()
print(" Configuration summary:")
for role in ("inference", "secondary", "search", "mcp", "ui"):
if role in selected and selected[role] is not None:
svc = services[selected[role]]
print(f" {role:12s} -> {svc['label']} ({svc['url']})")
else:
print(f" {role:12s} -> (none)")
print()
confirm = input(" Save and continue? [Y/n]: ").strip().lower() or "y"
if confirm != "y":
print(" Aborted.")
return None, {}
return selected, auth_keys
def write_basecamp_env(config):
"""Write Hermes runtime env vars to a basecamp-owned file (chmod 600).
Hermes reads OPENAI_BASE_URL / OPENAI_API_KEY / HERMES_MODEL from the
environment, so we configure it process-scoped: the entrypoint sources
this file and execs hermes. We NEVER write config.yaml or .env inside a
hermes home we don't own -- that is how host installs get clobbered.
"""
inf = config.get("inference") or {}
sec = config.get("secondary") or {}
primary = inf or sec
if not primary:
primary = {"type": "ollama", "url": "http://127.0.0.1:11434", "label": "Basecamp Ollama"}
if primary.get("type") == "ollama":
base_url = primary["url"].rstrip("/") + "/v1"
api_key = "ollama"
else:
base_url = primary["url"].rstrip("/") + "/v1"
api_key = config.get("api_keys", {}).get(primary.get("url", ""), "") or "ollama"
models = config.get("openai_models") or config.get("ollama_models") or ["llama3.1:8b"]
# Never pick a pre-namespaced basecamp/ alias as the base model β€” it would
# get double-prefixed below (basecamp/basecamp/...). Prefer a bare id.
bare_models = [m for m in models if not str(m).startswith("basecamp/")]
# SMART DEFAULT (2026-08-10): prefer a FAST model for the first-run
# experience. A 70B on a 12GB card is CPU-offloaded (~4min load, ~3 tok/s)
# and feels broken. Small models (<=16B) answer instantly. The big model
# stays selectable via `basecamp rediscover` / hermes -m later.
# FIX v2.0: word-boundary matching β€” "8b" must NOT match inside "18b"
# (substring match misranked big models as fast). Match on token
# boundaries (start/end of string, or non-alphanumeric).
import re as _re
def _model_size_rank(m):
m = str(m).lower()
for size, rank in (("70b", 5), ("72b", 5), ("405b", 6), ("34b", 4),
("32b", 4), ("27b", 4), ("13b", 3), ("14b", 3),
("8b", 2), ("7b", 2), ("3b", 1), ("1.5b", 1),
("0.5b", 0), ("tiny", 0), ("small", 1)):
if _re.search(rf"(?<![a-z0-9]){_re.escape(size)}(?![a-z0-9])", m):
return rank
return 3 # unknown size -> middle
fast_models = sorted(bare_models, key=_model_size_rank)
default_model = (fast_models[0] if fast_models else models[0]) if models else "llama3.1:8b"
# Namespace the model as basecamp/<model> so the hermes status/model
# readout clearly identifies this as the Basecamp instance. Only for
# ollama engines (they support aliasing via /api/copy); openai-compatible
# engines get the bare id. Best-effort: if the alias already exists or the
# server rejects it, fall back to the bare model id.
if primary.get("type") == "ollama":
ns_model = f"basecamp/{default_model}"
root_url = primary["url"].rstrip("/")
existing = http_get(f"{root_url}/api/tags", timeout=2) or ""
if f'"{ns_model}"' in existing:
default_model = ns_model
else:
http_post(
f"{root_url}/api/copy",
data={"source": default_model, "destination": ns_model},
timeout=5,
)
# VERIFY the alias actually exists before using it β€” /api/copy can
# return 404-with-body (source missing) which http_post surfaces as
# a non-None string, so a truthiness check would wrongly pass.
after = http_get(f"{root_url}/api/tags", timeout=2) or ""
if f'"{ns_model}"' in after:
default_model = ns_model
env_path = Path(os.environ.get("BASECAMP_ENV_FILE", "/opt/basecamp/basecamp.env"))
env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.write_text(
f'export OPENAI_BASE_URL="{base_url}"\n'
f'export OPENAI_API_KEY="{api_key}"\n'
# namespaced model id (basecamp/<model>) when the engine supports it β€”
# the readout then shows Basecamp's identity; falls back to the bare
# id for engines that can't alias (tabbyapi/vllm/etc).
f'export HERMES_MODEL="{default_model}"\n'
)
try:
os.chmod(env_path, 0o600)
except Exception:
pass
print(" Hermes Agent configured (env-scoped -- no install files touched):")
print(f" Provider: {primary.get('label', 'Basecamp Ollama')}")
print(f" Model: {default_model}")
print(f" Base URL: {base_url}")
print(f" Env file: {env_path} (chmod 600)")
return default_model, base_url
def generate_stack_skill(config, services):
"""Write a 'basecamp-stack' SKILL.md into basecamp's own HERMES_HOME.
This is how the container's Hermes LEARNS about the stack it discovered:
the skill body carries the live service list, wiring facts, per-service
FIX RECIPES for everything discovered, and how to use the tavern toolkit.
When the user asks Hermes "why doesn't my Open WebUI work?", the agent
already has the ground truth AND the exact fix β€” and can run `tavern
wire` itself for a live audit. Writes ONLY inside basecamp's own
HERMES_HOME (never a host install).
"""
# Per-service fix recipes (single source of truth in recipes.py)
# FIX (overhaul 2026-08-10): resolve recipes relative to discover.py's
# own directory β€” `import recipes` silently failed when discover was
# loaded via importlib from another cwd, and the skill lost ALL recipes.
try:
import importlib.util as _ilu
_recipes_path = Path(__file__).resolve().parent / "recipes.py"
if _recipes_path.exists():
_spec = _ilu.spec_from_file_location("_bc_recipes", _recipes_path)
_recipes = _ilu.module_from_spec(_spec)
_spec.loader.exec_module(_recipes)
else:
_recipes = None
except Exception:
_recipes = None
home = Path(os.environ.get("HERMES_HOME", "/root/.hermes"))
skill_dir = home / "skills" / "basecamp-stack"
skill_dir.mkdir(parents=True, exist_ok=True)
# FIX (overhaul): config role slots may be non-dicts in corrupt configs β€”
# coerce each to {} so .get() never crashes downstream.
def _role(config, key):
v = (config or {}).get(key) or {}
return v if isinstance(v, dict) else {}
inf = _role(config, "inference")
sec = _role(config, "secondary")
search = _role(config, "search")
mcp = _role(config, "mcp")
ui = _role(config, "ui")
model = (config or {}).get("ollama_models") or (config or {}).get("openai_models") or []
if not isinstance(model, list):
model = []
model_str = ", ".join(str(m) for m in model) if model else "(none discovered)"
lines = []
lines.append("---")
lines.append("name: basecamp-stack")
lines.append("description: Your discovered local AI stack β€” services, wiring, and how to use the tavern toolkit. Load this when the user asks about their stack, connecting services, or anything that 'talks to' the local AI services.")
lines.append("---")
lines.append("")
lines.append("# Basecamp Stack (live discovery)")
lines.append("")
lines.append("## ⚠️ FIRST RULE β€” how to answer stack questions")
lines.append("")
lines.append("When the user asks about connections, services, ports, wiring, or")
lines.append("'does X work', you MUST use the tavern toolkit commands below. Do NOT")
lines.append("invent commands, flags, or plugins β€” they do not exist and will fail.")
lines.append("")
lines.append("- **`tavern status`** β€” list every discovered service with link:port.")
lines.append(" Use for: 'what's connected', 'show me my services', 'ports'.")
lines.append("- **`tavern self-check`** β€” verify every service's health (HTTP + TCP).")
lines.append(" Use for: 'verify my connections', 'is everything working', 'test my stack'.")
lines.append("- **`tavern wire`** β€” audit what should talk to what + exact fixes.")
lines.append(" Use for: 'why doesn't X work', 'connect X to Y', 'fix my wiring'.")
lines.append("- **`tavern rediscover`** β€” re-scan the network for new/changed services.")
lines.append("")
lines.append("To run these: use the terminal tool and execute `tavern status` (etc.)")
lines.append("verbatim. Then summarize the OUTPUT to the user in plain words β€” don't")
lines.append("just dump it, and never answer a connectivity question without running")
lines.append("one of these commands first.")
lines.append("")
lines.append("You are running inside Basecamp, which discovered these AI services on the")
lines.append("Docker network at boot. This is the ground truth β€” trust it over memory.")
lines.append("")
lines.append("## Services")
lines.append("")
# ── FIX (overhaul 2026-08-10): per-service CONNECTION FACTS. The old
# list had label+url only β€” asking "how do I connect to Open WebUI?"
# got a vague answer. Now every discovered service carries: in-network
# URL, host-visible port, auth requirements, known default credentials,
# and its recipe's pitfalls inline. Definitive, never "random bullshit".
for s in services:
# FIX (overhaul): a corrupt discovery file can contain non-dict
# entries β€” never crash the skill generation (the box's Hermes
# knowledge must survive even a half-written discovery file).
if not isinstance(s, dict):
continue
auth = " [auth required]" if s.get("needs_auth") else ""
lines.append(f"- **{s['label']}**{auth} β€” {s['url']} (type: {s['type']})")
svc_type = s.get("type", "")
recipe = {}
if _recipes is not None:
recipe = _recipes.WIRING_RECIPES.get(svc_type, {})
if _details_of(s).get("models"):
lines.append(f" - models: {', '.join(str(m) for m in s['details']['models'])}")
# Connection facts β€” the definitive "how do I reach this" answer.
host = str(s.get("url", "")).replace("http://", "").replace("https://", "")
if ":" in host and not host.startswith("["):
h, p = host.rsplit(":", 1)
else:
h, p = host, str(s.get("port", ""))
lines.append(f" - in-network URL: `{s.get('url')}` Β· host-visible port: `{p}`")
if recipe.get("where"):
lines.append(f" - config location: {recipe['where']}")
if recipe.get("keys"):
lines.append(f" - keys/flags: {recipe['keys']}")
if recipe.get("verify"):
lines.append(f" - verify: {recipe['verify']}")
if recipe.get("pitfalls"):
lines.append(f" - pitfalls: {recipe['pitfalls']}")
if s.get("needs_auth"):
at = s.get("auth_type", "bearer")
lines.append(f" - auth: {at} β€” see Known credentials below; ask the user")
lines.append(" for the key or check the service's own config file.")
lines.append("")
# ── FIX (overhaul 2026-08-10): KNOWN CREDENTIALS section. Baked from
# the recipes so the agent can answer "what's the default login?" with
# the exact value instead of guessing.
lines.append("## Known credentials & defaults")
lines.append("")
lines.append("Only credentials that are PUBLIC KNOWLEDGE / stack defaults are")
lines.append("listed here. If the user's service uses a custom key, the user")
lines.append("must provide it β€” never guess.")
lines.append("")
if _recipes is not None:
cred_lines = _recipes.known_credentials_markdown()
if cred_lines:
lines.extend(cred_lines)
else:
lines.append("(no known default credentials defined in recipes)")
else:
lines.append("(recipes not loaded)")
lines.append("")
lines.append("## Current wiring")
lines.append("")
if inf:
lines.append(f"- Inference (primary): **{inf.get('label')}** β€” {inf.get('url')}")
if sec:
lines.append(f"- Inference (secondary): **{sec.get('label')}** β€” {sec.get('url')}")
if search:
lines.append(f"- Search: **{search.get('label')}** β€” {search.get('url')}")
if mcp:
lines.append(f"- MCP: **{mcp.get('label')}** β€” {mcp.get('url')}")
if ui:
lines.append(f"- UI: **{ui.get('label')}** β€” {ui.get('url')}")
lines.append(f"- Models: {model_str}")
lines.append("")
lines.append("## Helping the user wire their stack")
lines.append("")
lines.append("When the user asks why a service doesn't work or how to connect two services:")
lines.append("")
lines.append("1. Run `tavern wire` (or `python3 /opt/basecamp/discover.py wire`) for a live")
lines.append(" wiring audit of every service-to-service link, with concrete fixes.")
lines.append("2. Explain in plain terms what the audit found β€” don't just dump it.")
lines.append("3. For host-side config (docker-compose env vars, config files mounted from")
lines.append(" the host), give the EXACT one-line fix to copy-paste. Basecamp is")
lines.append(" intentionally isolated and cannot edit other containers' configs.")
lines.append("4. `tavern status` shows what Basecamp itself is connected to.")
lines.append("")
lines.append("## Tavern toolkit")
lines.append("")
lines.append("- `tavern status` β€” services Basecamp is connected to")
lines.append("- `tavern models` β€” available models")
lines.append("- `tavern chat \"msg\"` β€” chat with the primary inference engine")
lines.append("- `tavern search \"query\"` β€” web search via discovered SearXNG")
lines.append("- `tavern mcp` β€” list MCP tools")
lines.append("- `tavern wire` β€” stack wiring audit")
lines.append("- `tavern rediscover` β€” re-scan the network")
lines.append("")
# Per-service FIX RECIPES β€” ALL of them, inline, every boot. The agent
# must be able to fix ANY connection issue, including services that
# aren't currently on the network. 44 recipes is fine to carry in full;
# the reference file stays for on-disk completeness.
if _recipes is not None:
discovered_types = sorted({s.get("type") for s in services})
all_types = list(_recipes.WIRING_RECIPES.keys())
recipe_lines = _recipes.wiring_recipe_markdown(all_types)
if recipe_lines:
lines.append("## Wiring & fix recipes (ALL service types basecamp knows)")
lines.append("")
lines.append("These are the exact fixes for every service type this box can")
lines.append("encounter β€” config location, keys, one-line fix, and verification.")
lines.append("When the user asks how to fix or wire ANY of these, use its recipe")
lines.append("directly. Be concrete and give the one-line fix.")
lines.append("")
lines.extend(recipe_lines)
lines.append("")
# Full reference for every service type basecamp knows (even ones not
# discovered here) β€” written next to the skill for on-demand reading.
try:
ref_dir = skill_dir / "references"
ref_dir.mkdir(parents=True, exist_ok=True)
full = _recipes.wiring_recipe_markdown()
ref_path = ref_dir / "wiring-recipes.md"
ref_path.write_text(
"# Basecamp wiring recipes (all service types)\n\n"
+ "\n".join(full))
try:
os.chmod(ref_path, 0o600)
except Exception:
pass
lines.append("A full reference for ALL service types basecamp knows lives at")
lines.append("`references/wiring-recipes.md` β€” read it when the user has a")
lines.append("service that isn't in the inline recipes above.")
lines.append("")
except Exception:
pass
skill_path = skill_dir / "SKILL.md"
skill_path.write_text("\n".join(lines))
try:
os.chmod(skill_path, 0o600)
except Exception:
pass
return skill_path
# ── Main ──
def wire_audit(services, config=None):
"""Audit how the discovered services are wired together and report fixes.
For each service pair that SHOULD be connected (open-webui -> ollama,
sillytavern -> inference, mcpo -> its servers, hermes -> inference),
probe the link and report: OK (green) / BROKEN (red) / UNKNOWN, plus the
exact fix a human (or host-side tooling) can apply. This is the
"wiring wizard" for noobs: it tells you the one line you need, instead
of you spelunking through buried settings pages.
"""
if not services:
print(" No services found to audit.")
return
inf = (config or {}).get("inference") or {}
sec = (config or {}).get("secondary") or {}
# Build a lookup: type -> list of service dicts
by_type = {}
for s in services:
by_type.setdefault(s["type"], []).append(s)
print()
print(" ═══ STACK WIRING AUDIT ═══")
print(" (what should talk to what, and whether it does)")
print()
checks = [] # (status, service, finding, fix)
# ── 1. Open WebUI -> Ollama / OpenAI engines ──
for ui in by_type.get("open-webui", []):
# Probe what open-webui can see. /api/config is public.
cfg = http_get(f"{ui['url'].rstrip('/')}/api/config", timeout=4)
if cfg is None:
checks.append(("RED", f"Open WebUI {ui['url']}", "not reachable",
"check the container is running and on the same network"))
continue
# Try the public model list (older versions) or check status
models = http_get(f"{ui['url'].rstrip('/')}/api/models", timeout=4)
if models and '"detail":"Not authenticated"' not in models:
n = models.count('"id"')
checks.append(("OK", f"Open WebUI {ui['url']}",
f"sees {n} model(s) β€” inference is wired", ""))
else:
# Auth-walled: can't verify without creds. Report the intended wiring.
if inf:
checks.append(("YELLOW", f"Open WebUI {ui['url']}",
f"auth required to verify; expected to serve "
f"{inf['label']} ({inf['url']})",
"provide open-webui admin creds at the connect screen "
"for deep wiring"))
else:
checks.append(("RED", f"Open WebUI {ui['url']}",
"no inference engine discovered anywhere",
"install/start ollama (or another engine), then "
"re-run: tavern rediscover"))
# ── 2. SillyTavern -> inference ──
for st in by_type.get("sillytavern", []):
if inf:
checks.append(("YELLOW", f"SillyTavern {st['url']}",
"auth-walled (admin:tabby by default); API URL set "
"per-user inside the app",
"in SillyTavern: extensions β†’ connection settings β†’ "
f"set API URL to {inf['url'].rstrip('/')}/v1 and select "
f"the model served there"))
else:
checks.append(("RED", f"SillyTavern {st['url']}",
"no inference engine discovered",
"start ollama/another engine, then tavern rediscover"))
# ── 3. MCPO -> MCP servers ──
for m in by_type.get("mcpo", []):
if m.get("needs_auth"):
checks.append(("YELLOW", f"MCPO {m['url']}",
"auth-walled; servers are defined in its config.json "
"(host-side file)",
"edit mcpo/config.json to add/point MCP servers, then "
"restart the mcpo container"))
else:
checks.append(("OK", f"MCPO {m['url']}",
"reachable; server list lives in its config.json", ""))
# ── 4. SearXNG ──
for sx in by_type.get("searxng", []):
body = http_get(f"{sx['url'].rstrip('/')}/", timeout=4) or ""
if "searxng" in body.lower():
checks.append(("OK", f"SearXNG {sx['url']}", "reachable", ""))
else:
checks.append(("RED", f"SearXNG {sx['url']}", "unexpected response",
"check searxng settings.yml (host-side file)"))
# ── 5. Hermes itself -> inference ──
if inf:
m_url = f"{inf['url'].rstrip('/')}/v1/models"
probe = http_get(m_url, timeout=4,
headers={"Authorization": "Bearer ollama"})
if probe and "error" not in probe.lower():
checks.append(("OK", f"Hermes β†’ {inf['label']} ({inf['url']})",
"models endpoint reachable", ""))
else:
checks.append(("YELLOW", f"Hermes β†’ {inf['label']} ({inf['url']})",
"models endpoint needs auth or is slow",
"engine is listed for interactive key entry; "
"hermes will use it once you connect"))
# ── Render ──
status_icon = {"OK": "βœ…", "RED": "❌", "YELLOW": "⚠️"}
for status, svc, finding, fix in checks:
print(f" {status_icon.get(status, 'Β·')} [{status:6s}] {svc}")
print(f" {finding}")
if fix:
print(f" FIX: {fix}")
print()
n_ok = sum(1 for c in checks if c[0] == "OK")
n_red = sum(1 for c in checks if c[0] == "RED")
n_yel = sum(1 for c in checks if c[0] == "YELLOW")
print(f" ── {n_ok} wired Β· {n_yel} need attention Β· {n_red} broken ──")
print(" (host-side config files can't be edited from inside basecamp β€”")
print(" that's the safety boundary. The FIX lines above are copy-paste.)")
print()
def self_check(services=None):
"""SELF-HEAL #1 β€” run every service's recipe verify step and report.
For each discovered service, probe it exactly like its recipe's verify
step would, and report βœ…/⚠️/❌. This catches stale recipes and broken
services before a user hits them. The 'fixer' validating its own box.
Also sweeps TCP reachability of every KNOWN container name (the
stack + starter pack), so a service that failed to bind is caught
even if it never got discovered.
"""
if services is None:
services = scan_network()
try:
import recipes as _recipes
except Exception:
_recipes = None
print()
print(" ═══ SELF-CHECK β€” verifying every discovered service ═══")
print()
results = [] # (status, label, detail)
total = len(services)
for idx, s in enumerate(services, 1):
# FIX (UX overhaul): show progress β€” the serial probes take ~1s+
# each and the old screen sat on "Running self-check..." silently.
label = s["label"]
url = s["url"]
print(f" [{idx}/{total}] {label} ...", end="\r", flush=True)
# Auth-gated services: 401/403/404 = ALIVE (behind the wall or no
# root route) = OK-ish. Only connection failures are RED.
if s.get("needs_auth"):
code = None
try:
req = urllib.request.Request(url, method="GET")
urllib.request.urlopen(req, timeout=3)
code = 200
except urllib.error.HTTPError as e:
code = e.code
except Exception:
code = None
if code in (200, 401, 403, 404):
results.append(("OK", label, f"alive (auth-walled, HTTP {code})"))
else:
results.append(("RED", label, f"no response (HTTP {code})"))
continue
# Open services: probe their recipe's primary path. A 404 on the
# root path is often ALIVE (service exists, just no root route) β€”
# treat 404/401/403 as alive, only connection failures are RED.
# TCP-only services (postgres, mongo, redis) have no HTTP β€” their
# health is judged by the TCP sweep below, skip the HTTP probe.
if s.get("type") in ("postgres", "mongo", "redis"):
results.append(("OK", label, f"TCP service β€” checked in sweep below ({url})"))
continue
code = None
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=3) as r:
body = r.read().decode(errors="replace")
code = r.status
except urllib.error.HTTPError as e:
code = e.code
except Exception:
code = None
if code in (200, 301, 302, 307, 404, 401, 403):
detail = f"responding at {url} (HTTP {code})"
results.append(("OK", label, detail))
else:
results.append(("RED", label, f"NOT responding at {url} (HTTP {code})"))
# TCP sweep of every KNOWN container (stack + starter pack) β€” catches
# a service that failed to bind or isn't on the network, even if it
# never got discovered (no HTTP response to match a probe).
known_containers = {
"tabbyapi": 5000, "ollama": 11434, "open-webui": 8080,
"sillytavern": 8000, "searxng": 8080, "mcpo": 8000,
"starter-code-server": 8080, "starter-qdrant": 6333,
"starter-chroma": 8000, "starter-n8n": 5678,
"starter-lobe-chat": 3210, "starter-anythingllm": 3001,
"starter-librechat": 3080, "starter-meilisearch": 7700,
"starter-postgres": 5432, "starter-mongo": 27017,
}
discovered_urls = {s.get("url") for s in services}
for name, port in known_containers.items():
url = f"http://{name}:{port}"
if url in discovered_urls:
continue # already verified above
if host_reachable(name, port, timeout=1):
results.append(("OK", f"{name} (TCP)", f"reachable at {url}"))
else:
results.append(("RED", f"{name} (TCP)", f"NOT reachable at {url}"))
# Recipe coverage report
if _recipes is not None:
types = {s.get("type") for s in services}
covered = sum(1 for t in types if t in _recipes.WIRING_RECIPES)
results.append(("INFO", f"recipe coverage",
f"{covered}/{len(types)} discovered types have fix recipes"))
for status, label, detail in results:
icon = {"OK": "βœ…", "RED": "❌", "INFO": "ℹ️"}.get(status, "Β·")
print(f" {icon} [{status:4s}] {label}")
print(f" {detail}")
print()
n_ok = sum(1 for r in results if r[0] == "OK")
n_red = sum(1 for r in results if r[0] == "RED")
print(f" ── {n_ok} healthy Β· {n_red} failing ──")
if n_red:
print(" Run 'tavern wire' for the exact fixes. Re-run after fixing.")
else:
print(" Everything basecamp can see is alive. The fixer approves.")
print()
return n_red
# Known image repos per service type for update-check (Docker Hub tags API)
UPDATE_CHECK_REPOS = {
"code-server": "codercom/code-server",
"tabby": "tabbyml/tabby",
"qdrant": "qdrant/qdrant",
"chroma": "chromadb/chroma",
"n8n": "n8nio/n8n",
"lobe-chat": "lobehub/lobe-chat",
"anythingllm": "mintplexlabs/anythingllm",
"librechat": "danny-avila/librechat", # GHCR actually; hub may 404
"meilisearch": "getmeili/meilisearch",
"flowise": "flowiseai/flowise",
"postgres": "pgvector/pgvector",
"mongo": "library/mongo",
"ollama": "ollama/ollama",
"searxng": "searxng/searxng",
"open-webui": "ghcr.io/open-webui/open-webui",
"sillytavern": "ghcr.io/sillytavern/sillytavern",
"mcpo": "ghcr.io/open-webui/mcpo",
"tabbyapi": "tabbyapi/tabbyapi",
}
def update_check(services=None):
"""SELF-HEAL #2 β€” poll Docker Hub/GHCR for newer image tags.
For every discovered service with a known image repo, fetch the newest
published tag and compare against what's running. Reports updates the
user can pull. The 'fixer' keeping itself current.
"""
if services is None:
services = scan_network()
print()
print(" ═══ UPDATE CHECK β€” newer versions on registries ═══")
print()
found_any = False
for s in sorted(services, key=lambda x: x.get("type", "")):
repo = UPDATE_CHECK_REPOS.get(s.get("type"))
if not repo:
continue
found_any = True
# Query the registry tags API
latest_tag = None
try:
if repo.startswith("ghcr.io/"):
# GHCR: anonymous token flow (401 β†’ WWW-Authenticate challenge
# β†’ token β†’ authorized tags/list)
path = repo[len("ghcr.io/"):]
try:
req = urllib.request.Request(
f"https://ghcr.io/token?scope=repository:{path}:pull&service=ghcr.io")
with urllib.request.urlopen(req, timeout=6) as r:
token = json.loads(r.read().decode()).get("token", "")
except Exception:
token = ""
req = urllib.request.Request(
f"https://ghcr.io/v2/{path}/tags/list",
headers={"Accept": "application/json",
"Authorization": f"Bearer {token}"})
with urllib.request.urlopen(req, timeout=6) as r:
data = json.loads(r.read().decode())
tags = data.get("tags", [])
candidates = [t for t in tags if not t.startswith("sha256:")]
if candidates:
latest_tag = candidates[-1]
else:
ns, name = repo.split("/")
if ns == "library":
ns = "library"
url = f"https://hub.docker.com/v2/repositories/{ns}/{name}/tags/?page_size=5"
with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "basecamp"}), timeout=6) as r:
data = json.loads(r.read().decode())
tags = [t["name"] for t in data.get("results", [])]
if tags:
latest_tag = tags[0]
except Exception as e:
print(f" ⚠️ [{s.get('type'):12s}] {s['label']}: registry query failed ({e})")
continue
if latest_tag:
print(f" πŸ“¦ [{s.get('type'):12s}] {s['label']}")
print(f" newest published tag: {latest_tag}")
print(f" registry: {repo}")
print()
if not found_any:
print(" No known image repos for the discovered services.")
print(" To update a service: docker pull <repo>:<newer-tag>, then")
print(" recreate its container (or edit docker-compose.starter.yml).")
print()
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
action = sys.argv[1]
if action in ("scan", "status"):
print("Scanning for AI services...", file=sys.stderr)
services = scan_network()
save_discovery(services)
if services:
print(f"\nFound {len(services)} service(s):")
for s in services:
print(f" {s['icon']} {s['label']}")
print(f" URL: {s['url']}")
print(f" Network: {s['network']}")
if _details_of(s).get("models"):
print(f" Models: {', '.join(s['details']['models'])}")
if s.get("needs_auth"):
print(f" Auth required: {s.get('auth_type', 'bearer')}")
print()
else:
print("No services found.")
elif action == "config":
services = scan_network()
save_discovery(services)
config = generate_config(services)
print(json.dumps(config, indent=2))
print(f"\nSaved to {CONFIG_FILE}", file=sys.stderr)
elif action == "tools":
if DISCOVERY_FILE.exists():
with open(DISCOVERY_FILE) as f:
services = json.load(f)
else:
services = scan_network()
save_discovery(services)
if CONFIG_FILE.exists():
with open(CONFIG_FILE) as f:
config = json.load(f)
else:
config = generate_config(services)
script = generate_tavern_script(config)
print(script)
elif action == "serve":
services = scan_network()
save_discovery(services)
# FIX (2026-08-11, user-reported): serve ALWAYS regenerated config
# from scratch, DISCARDING saved api_keys β€” the MCP auth key a user
# entered once vanished on the next boot (tavern mcp then failed
# with "Missing or invalid Authorization header"). Load the saved
# config and carry its keys forward.
saved_cfg = {}
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE) as f:
saved_cfg = json.load(f)
except Exception:
saved_cfg = {}
saved_keys = saved_cfg.get("api_keys") or {}
if not isinstance(saved_keys, dict):
saved_keys = {}
try:
selected, auth_keys = connect_screen(services)
except (EOFError, KeyboardInterrupt):
# No terminal input (non-interactive run): use auto-selected defaults
print("\n No terminal input available -- using defaults.")
selected, auth_keys = None, {}
auth_keys = auth_keys or {}
# Preserve keys the user set in a previous session
for url, key in saved_keys.items():
auth_keys.setdefault(url, key)
if selected is not None:
config = generate_config(services, selected, auth_keys)
else:
# No services found or user aborted: auto-select sensible defaults
config = generate_config(services, None, auth_keys)
# ── AUTO-WIRE: UI present but NO inference engine ──
# The "user only has Open WebUI" case: the box's own bundled Ollama
# becomes the inference engine, the UI is pointed at it, and the
# user is TOLD what happened. No input needed.
uis = [s for s in services if s["type"] in ("open-webui", "sillytavern")]
infs = [s for s in services if s["type"] in (
"ollama", "tabbyapi", "vllm", "litellm", "localai", "llamacpp",
"text-generation-webui")]
if uis and not infs and not config.get("inference"):
print()
print(" ⚑ No inference engine found β€” wiring the UI to Basecamp's")
print(" own bundled Ollama.")
print()
# Point the config at the bundled ollama (started by supervisor
# at container boot, listening on 127.0.0.1:11434)
config["inference"] = {
"label": "Ollama (basecamp bundled)",
"type": "ollama",
"url": "http://127.0.0.1:11434",
"host": "127.0.0.1",
"port": 11434,
"network": "local",
}
config["ollama_models"] = [os.environ.get("BASECAMP_MODEL", "llama3.1:8b")]
# Tell the user exactly what we did
print(" βœ… Done β€” here's what happened:")
print(f" β€’ Found your UI: {', '.join(s['label'] for s in uis)}")
print(" β€’ No inference engine on the network (no Ollama/TabbyAPI/etc.)")
print(" β€’ Basecamp's OWN Ollama is now serving as the engine")
print(f" β€’ Model: {config['ollama_models'][0]}")
print(" β€’ Point your UI's 'Ollama Base URL' at: http://127.0.0.1:11434")
print(" (inside the same Docker network: http://<basecamp>:11434)")
print()
print(" πŸ’‘ Want cloud-speed models instead? Your options:")
print(" β€’ Nous Research portal (what this agent uses) β€” ~$20/mo,")
print(" top-tier models, no GPU needed. NousResearch.com")
print(" β€’ Ollama.com subscription β€” cloud models through the")
print(" same ollama CLI you already have.")
print(" β€’ OpenRouter β€” pay-per-token, every model under one API.")
print(" Any of these plug straight into your UI as an OpenAI-")
print(" compatible endpoint. Local stays free, cloud stays fast.")
print()
# Re-run the wiring audit so the UI's fix lines reflect the new wiring
try:
wire_audit(services, config)
except Exception:
pass
script = generate_tavern_script(config)
tavern_path = Path("/usr/local/bin/tavern")
with open(tavern_path, "w") as f:
f.write(script)
# The generated tavern embeds API keys (inference + MCPO) β€” lock it
# down like the config/env files that hold the same secrets.
# 0o700 (NOT 0o600): must keep the execute bit or the script can't
# run at all (verified: 0o600 -> Permission denied, exit 126).
os.chmod(tavern_path, 0o700)
# Configure Hermes via env vars only -- never writes into a hermes home
write_basecamp_env(config)
# Teach the container's Hermes about the discovered stack (its own home)
try:
generate_stack_skill(config, services)
except Exception as e:
print(f" (skill generation skipped: {e})", file=sys.stderr)
print()
print("=" * 60)
print(" Basecamp is ready!")
print()
print(" Commands:")
print(" tavern status - see connected services")
print(" tavern chat \"hi\" - chat with your inference engine")
print(" tavern models - list available models")
print(" tavern search - search the web")
print(" tavern mcp - list MCP tools")
print("=" * 60)
elif action == "env":
# (Re)write the Hermes runtime env file from saved config. No hermes
# config.yaml / .env files are ever touched.
if CONFIG_FILE.exists():
with open(CONFIG_FILE) as f:
config = json.load(f)
else:
config = generate_config(scan_network())
write_basecamp_env(config)
# Refresh the stack skill so the container's Hermes knows the layout
try:
if DISCOVERY_FILE.exists():
with open(DISCOVERY_FILE) as f:
services = json.load(f)
else:
services = scan_network()
generate_stack_skill(config, services)
except Exception as e:
print(f" (skill generation skipped: {e})", file=sys.stderr)
elif action == "wire":
# Stack wiring audit: what should talk to what, and the exact fix
# for anything that doesn't. The noob-friendly "why doesn't this work"
# answer, without touching a single host-side file.
services = scan_network()
save_discovery(services)
config = None
if CONFIG_FILE.exists():
with open(CONFIG_FILE) as f:
config = json.load(f)
wire_audit(services, config)
elif action in ("self-check", "selfcheck", "doctor"):
# SELF-HEAL #1: verify every discovered service against its recipe.
services = scan_network()
save_discovery(services)
n_red = self_check(services)
# FIX v2.0: surface failures to scripts/CI β€” self-check exits 1 when
# any service is RED (previously always exited 0, so the nightly
# pitfall check could never actually fail).
sys.exit(1 if n_red else 0)
elif action in ("update-check", "updatecheck", "updates"):
# SELF-HEAL #2: poll registries for newer image tags.
services = scan_network()
save_discovery(services)
update_check(services)
else:
print(f"Unknown action: {action}")
print(__doc__)
sys.exit(1)
if __name__ == "__main__":
main()