feat: add self-hosted SearXNG research mode
CI / compose (push) Successful in 13m39s

This commit is contained in:
Marco0300
2026-09-03 21:49:02 +02:00
parent 0f70674b16
commit d298a98723
11 changed files with 175 additions and 42 deletions
+8 -2
View File
@@ -20,7 +20,7 @@ AUTOMATED_OUTREACH_ENABLED=false
# they are never copied into API responses. Connectivity tests use bounded GET
# requests only and report outbound_calls=false (no outreach is implemented).
# The following variables are legacy/bootstrap fallback values only.
AI_RESEARCH_PROVIDER=
# AI_RESEARCH_PROVIDER is set below for the primary self-hosted mode.
# NOUS_MODEL=Hermes-4-405B
# NOUS_BASE_URL=https://inference-api.nousresearch.com/v1
# NOUS_ALLOWED_HOSTS=inference-api.nousresearch.com
@@ -28,11 +28,17 @@ AI_RESEARCH_PROVIDER=
# FIRECRAWL_BASE_URL=https://api.firecrawl.dev/v2
# FIRECRAWL_ALLOWED_HOSTS=api.firecrawl.dev
# FIRECRAWL_API_KEY=<Firecrawl API key; secret-manager only>
AI_RESEARCH_PROVIDER=
# Primary no-paid-scraper mode: Nous orchestrates, internal SearXNG searches,
# and the API's SSRF-safe crawler reads pages. No Firecrawl key is required.
AI_RESEARCH_PROVIDER=nous_portal
NOUS_API_KEY=
NOUS_MODEL=Hermes-4-405B
NOUS_BASE_URL=https://inference-api.nousresearch.com/v1
NOUS_ALLOWED_HOSTS=inference-api.nousresearch.com
SEARXNG_BASE_URL=http://searxng:8080
SEARXNG_ALLOWED_HOSTS=searxng
SEARXNG_SECRET_KEY=
# Optional legacy fallback only; never required by self-hosted mode.
FIRECRAWL_API_KEY=
FIRECRAWL_BASE_URL=https://api.firecrawl.dev/v2
FIRECRAWL_ALLOWED_HOSTS=api.firecrawl.dev
+6 -7
View File
@@ -31,13 +31,12 @@ The provider status is available at authenticated `GET
`/api/v1/discovery/provider-status` alias is retained). Configure only on the
server. The native Nous adapter uses OpenAI-compatible Chat Completions at
`https://inference-api.nousresearch.com/v1/chat/completions` and strict
`web_search`/`scrape_website` tools backed by an allowlisted Firecrawl-compatible
API. Configure server-side `NOUS_API_KEY`, `NOUS_MODEL`, `NOUS_BASE_URL`,
`NOUS_ALLOWED_HOSTS`, `FIRECRAWL_API_KEY`, `FIRECRAWL_BASE_URL`, and
`FIRECRAWL_ALLOWED_HOSTS` with `AI_RESEARCH_PROVIDER=nous_portal`. Tool calls,
responses, criteria, and results are bounded; page text is untrusted; only
structured HTTPS targets are accepted and the existing SSRF-safe crawler fetches
and persists evidence. Status is fail-closed and never returns secrets.
`web_search`/`scrape_website` tools. In the primary no-paid-scraper mode,
`web_search` uses internal SearXNG (`SEARXNG_BASE_URL`, normally
`http://searxng:8080`) and `scrape_website` uses the existing SSRF-safe scanner;
configure server-side `NOUS_API_KEY`, `NOUS_MODEL`, `NOUS_BASE_URL`,
`NOUS_ALLOWED_HOSTS`, `SEARXNG_ALLOWED_HOSTS`, and `SEARXNG_SECRET_KEY`.
Firecrawl settings are optional legacy compatibility only.
All protected endpoints require the server-side session cookie. Every query is constrained by the authenticated user's `organization_id`; IDs from another tenant behave as not found and must not disclose whether a record exists.
+47 -12
View File
@@ -1,9 +1,8 @@
"""Fail-closed AI web-research providers for criteria-first discovery.
The native Nous adapter is a locator only. It may ask an approved Firecrawl-
compatible service for bounded search/scrape observations, but only structured
HTTPS targets returned by the model are handed to discovery.py. The existing
crawler performs the final SSRF validation and persists the evidence.
Nous is the model/orchestrator. Search is provided by an internal-only SearXNG
service and page reads use ProspectOS's own SSRF-safe website scanner. Firecrawl
remains a backwards-compatible legacy path when explicitly configured.
"""
from __future__ import annotations
@@ -11,10 +10,11 @@ import json
import os
import re
import sqlite3
from html import unescape
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from .website_scanner import validate_url
from .website_scanner import scan_website, validate_url
MAX_CANDIDATES = 50
MAX_CRITERIA_BYTES = 8192
@@ -61,7 +61,7 @@ def _config():
if row:
from .provider_config import decrypt
credentials = json.loads(decrypt(row["credentials_ciphertext"])) if row["credentials_ciphertext"] else {}
return {"provider": row["provider"], "model": row["model"], "nous_url": row["nous_base_url"], "nous_allowed": {urlparse(row["nous_base_url"]).hostname}, "nous_key": credentials.get("nous_api_key", ""), "firecrawl_url": row["firecrawl_base_url"], "firecrawl_allowed": {urlparse(row["firecrawl_base_url"]).hostname}, "firecrawl_key": credentials.get("firecrawl_api_key", "")}
return {"provider": row["provider"], "model": row["model"], "nous_url": row["nous_base_url"], "nous_allowed": {urlparse(row["nous_base_url"]).hostname}, "nous_key": credentials.get("nous_api_key", ""), "searxng_url": os.environ.get("SEARXNG_BASE_URL", "").strip(), "searxng_allowed": _hosts("SEARXNG_ALLOWED_HOSTS", "searxng"), "firecrawl_url": row["firecrawl_base_url"], "firecrawl_allowed": {urlparse(row["firecrawl_base_url"]).hostname}, "firecrawl_key": credentials.get("firecrawl_api_key", "")}
except Exception:
return {"provider": "", "model": "", "endpoint": "", "allowed": set(), "api_key": ""}
provider = os.environ.get("AI_RESEARCH_PROVIDER", "").strip().lower()
@@ -73,7 +73,8 @@ def _config():
return {"provider": provider, "model": os.environ.get("NOUS_MODEL", "Hermes-4-405B").strip(),
"nous_url": os.environ.get("NOUS_BASE_URL", "https://inference-api.nousresearch.com/v1").strip(),
"nous_allowed": _hosts("NOUS_ALLOWED_HOSTS", "inference-api.nousresearch.com"),
"nous_key": nous_key, "firecrawl_url": os.environ.get("FIRECRAWL_BASE_URL", "https://api.firecrawl.dev/v2").strip(),
"nous_key": nous_key, "searxng_url": os.environ.get("SEARXNG_BASE_URL", "").strip(),
"searxng_allowed": _hosts("SEARXNG_ALLOWED_HOSTS", "searxng"), "firecrawl_url": os.environ.get("FIRECRAWL_BASE_URL", "https://api.firecrawl.dev/v2").strip(),
"firecrawl_allowed": _hosts("FIRECRAWL_ALLOWED_HOSTS", "api.firecrawl.dev"), "firecrawl_key": firecrawl_key}
api_key = generic_key
if provider == "openai_web_search": api_key = api_key or os.environ.get("OPENAI_API_KEY", "").strip()
@@ -85,9 +86,17 @@ def _config():
def _endpoint():
cfg = _config()
if cfg["provider"] in NOUS_PROVIDER_IDS:
if not cfg["model"] or not cfg["nous_key"] or not cfg["firecrawl_key"]:
if not cfg["model"] or not cfg["nous_key"]:
raise AIResearchConfigError("not_configured")
return cfg, _safe_endpoint(cfg["nous_url"], cfg["nous_allowed"]), _safe_endpoint(cfg["firecrawl_url"], cfg["firecrawl_allowed"])
nous = _safe_endpoint(cfg["nous_url"], cfg["nous_allowed"])
if cfg.get("searxng_url"):
parsed = urlparse(cfg["searxng_url"]); host = (parsed.hostname or "").lower().rstrip(".")
if parsed.scheme != "http" or host not in cfg["searxng_allowed"] or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise AIResearchConfigError("unsafe_provider")
return cfg, nous, cfg["searxng_url"].rstrip("/")
if not cfg["firecrawl_key"]:
raise AIResearchConfigError("not_configured")
return cfg, nous, _safe_endpoint(cfg["firecrawl_url"], cfg["firecrawl_allowed"])
if not cfg["provider"] or not cfg["endpoint"] or not cfg["model"]: raise AIResearchConfigError("not_configured")
if cfg["provider"] not in APPROVED_PROVIDER_IDS: raise AIResearchConfigError("unapproved_provider")
parsed = urlparse(cfg["endpoint"]); host = (parsed.hostname or "").lower().rstrip(".")
@@ -99,10 +108,13 @@ def _endpoint():
def provider_status() -> dict[str, object]:
cfg = _config()
if cfg["provider"] in NOUS_PROVIDER_IDS:
try: _, nous_url, firecrawl_url = _endpoint()
try: _, nous_url, tool_url = _endpoint()
except AIResearchConfigError as exc:
return {"provider": cfg["provider"], "status": str(exc), "configured": False, "network_enabled": False, "outbound_calls": False}
return {"provider": cfg["provider"], "model": cfg["model"], "nous_host": urlparse(nous_url).hostname, "firecrawl_host": urlparse(firecrawl_url).hostname, "status": "ready", "configured": True, "network_enabled": True, "outbound_calls": True, "max_candidates": MAX_CANDIDATES, "max_tool_calls": MAX_TOOL_CALLS}
result = {"provider": cfg["provider"], "model": cfg["model"], "nous_host": urlparse(nous_url).hostname, "status": "ready", "configured": True, "network_enabled": True, "outbound_calls": True, "max_candidates": MAX_CANDIDATES, "max_tool_calls": MAX_TOOL_CALLS}
if cfg.get("searxng_url"): result.update({"search_provider": "searxng", "searxng_host": urlparse(tool_url).hostname, "scrape_provider": "native_crawler"})
else: result.update({"search_provider": "firecrawl_legacy", "firecrawl_host": urlparse(tool_url).hostname, "scrape_provider": "firecrawl_legacy"})
return result
if not cfg["provider"] and not cfg["endpoint"]: return {"provider": "", "status": "not_configured", "configured": False, "network_enabled": False, "outbound_calls": False}
if cfg["provider"] and cfg["provider"] not in APPROVED_PROVIDER_IDS: return {"provider": cfg["provider"], "status": "unapproved_provider", "configured": False, "network_enabled": False, "outbound_calls": False}
try: _, endpoint, _ = _endpoint()
@@ -147,17 +159,35 @@ def _post(url: str, key: str, body_obj: dict, *, limit: int = MAX_RESPONSE_BYTES
return payload
def _page_text(html: str) -> str:
"""Return small, non-executable page text for the model."""
text = re.sub(r"(?is)<(script|style|noscript).*?>.*?</\1>", " ", html or "")
text = re.sub(r"(?s)<[^>]*>", " ", text)
return re.sub(r"\s+", " ", unescape(text)).strip()[:MAX_TOOL_RESULT_BYTES]
def _tool_result(cfg, name: str, arguments: str, remaining: int) -> dict:
if remaining < 0: raise AIResearchConfigError("tool_budget_exhausted")
try: args = json.loads(arguments or "{}")
except json.JSONDecodeError as exc: raise AIResearchConfigError("invalid_tool_arguments") from exc
if not isinstance(args, dict): raise AIResearchConfigError("invalid_tool_arguments")
base = _safe_endpoint(cfg["firecrawl_url"], cfg["firecrawl_allowed"])
if name == "web_search":
query = args.get("query")
try: requested_limit = int(args.get("limit", MAX_SEARCH_RESULTS))
except (TypeError, ValueError) as exc: raise AIResearchConfigError("invalid_tool_arguments") from exc
if not isinstance(query, str) or not query.strip() or len(query.encode()) > 1000 or not 1 <= requested_limit <= MAX_SEARCH_RESULTS: raise AIResearchConfigError("invalid_tool_arguments")
if cfg.get("searxng_url"):
request = Request(cfg["searxng_url"].rstrip("/") + "/search" + "?q=" + __import__("urllib.parse", fromlist=["quote"]).quote(query.strip()) + "&format=json", headers={"Accept": "application/json"}, method="GET")
try:
with urlopen(request, timeout=TIMEOUT_SECONDS) as response: raw = response.read(MAX_TOOL_RESULT_BYTES + 1)
except Exception as exc: raise AIResearchConfigError("provider_unavailable") from exc
if len(raw) > MAX_TOOL_RESULT_BYTES: raise AIResearchConfigError("provider_response_too_large")
try: payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise AIResearchConfigError("invalid_provider_response") from exc
if not isinstance(payload, dict): raise AIResearchConfigError("invalid_provider_response")
results = [{"title": x.get("title", "")[:300], "url": x.get("url", ""), "snippet": x.get("content", "")[:500]} for x in payload.get("results", [])[:requested_limit] if isinstance(x, dict) and isinstance(x.get("url"), str)]
return {"type": "web_search_result", "data": results}
base = _safe_endpoint(cfg["firecrawl_url"], cfg["firecrawl_allowed"])
payload = _post(base + "/search", cfg["firecrawl_key"], {"query": query.strip(), "limit": requested_limit}, limit=MAX_TOOL_RESULT_BYTES)
return {"type": "web_search_result", "data": payload.get("data", payload.get("results", []))}
if name == "scrape_website":
@@ -165,6 +195,11 @@ def _tool_result(cfg, name: str, arguments: str, remaining: int) -> dict:
if not isinstance(target, str) or urlparse(target).scheme != "https": raise AIResearchConfigError("invalid_tool_arguments")
try: safe = validate_url(target)
except (TypeError, ValueError) as exc: raise AIResearchConfigError("unsafe_target_url") from exc
if cfg.get("searxng_url"):
scanned = scan_website(safe, max_bytes=MAX_TOOL_RESULT_BYTES)
if scanned.get("error_code"): raise AIResearchConfigError("scrape_" + str(scanned["error_code"]))
return {"type": "scrape_result", "url": scanned.get("final_url") or safe, "data": {"status": scanned.get("status"), "title": scanned.get("title", ""), "description": scanned.get("meta_description", ""), "headings": scanned.get("headings", [])[:20], "content": _page_text(scanned.get("html", ""))}}
base = _safe_endpoint(cfg["firecrawl_url"], cfg["firecrawl_allowed"])
payload = _post(base + "/scrape", cfg["firecrawl_key"], {"url": safe, "formats": ["markdown"], "onlyMainContent": True}, limit=MAX_TOOL_RESULT_BYTES)
return {"type": "scrape_result", "url": safe, "data": payload.get("data", payload)}
raise AIResearchConfigError("unknown_tool")
+15 -3
View File
@@ -2,6 +2,7 @@
from dataclasses import dataclass
import os
from pathlib import Path
from urllib.parse import urlparse
class ConfigError(ValueError):
@@ -17,6 +18,8 @@ class Config:
log_level: str
ai_research_provider: str
ai_research_model: str
nous_base_url: str = ""
searxng_base_url: str = ""
def _env(values, key, default=""):
@@ -42,6 +45,15 @@ def load_config(values=None):
log_level = _env(values, "LOG_LEVEL", "INFO").upper()
if log_level not in {"QUIET", "ERROR", "WARNING", "INFO", "DEBUG"}:
raise ConfigError("LOG_LEVEL is invalid")
return Config(app_env, data_dir, secret, False, log_level,
_env(values, "AI_RESEARCH_PROVIDER").lower(),
_env(values, "AI_RESEARCH_PROVIDER_MODEL"))
provider = _env(values, "AI_RESEARCH_PROVIDER").lower()
nous_url = _env(values, "NOUS_BASE_URL", "https://inference-api.nousresearch.com/v1")
searx_url = _env(values, "SEARXNG_BASE_URL", "http://searxng:8080")
if provider in {"nous_portal", "nous_portal_web_research"}:
parsed = urlparse(nous_url); allowed = {x.strip().lower() for x in _env(values, "NOUS_ALLOWED_HOSTS", "inference-api.nousresearch.com").split(",") if x.strip()}
if parsed.scheme != "https" or parsed.hostname not in allowed or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ConfigError("NOUS_BASE_URL is unsafe")
parsed = urlparse(searx_url); allowed = {x.strip().lower() for x in _env(values, "SEARXNG_ALLOWED_HOSTS", "searxng").split(",") if x.strip()}
if parsed.scheme != "http" or parsed.hostname not in allowed or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ConfigError("SEARXNG_BASE_URL is unsafe")
return Config(app_env, data_dir, secret, False, log_level, provider,
_env(values, "AI_RESEARCH_PROVIDER_MODEL") or _env(values, "NOUS_MODEL", "Hermes-4-405B"), nous_url, searx_url)
+1 -1
View File
@@ -496,7 +496,7 @@ class ApiHandler(BaseHTTPRequestHandler):
except Exception: old = {}
for name in ("nous_api_key", "firecrawl_api_key"):
if name not in credentials and name in old: credentials[name] = old[name]
if config["enabled"] and any(name not in credentials for name in ("nous_api_key", "firecrawl_api_key")):
if config["enabled"] and ("nous_api_key" not in credentials or ("SEARXNG_BASE_URL" not in os.environ and "firecrawl_api_key" not in credentials)):
return self.send_json(400, {"error": "provider_credentials_required"})
ciphertext = encrypt_provider_secret(json.dumps(credentials, sort_keys=True)) if credentials else ""
fingerprint = hashlib.sha256(json.dumps(credentials, sort_keys=True).encode()).hexdigest() if credentials else ""
+1 -1
View File
@@ -86,7 +86,7 @@ def validate_payload(payload: dict) -> dict:
for field, default in urls.items():
value = str(payload.get(field, default)).strip().rstrip("/")
parsed = urlparse(value)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.fragment or parsed.query:
if (parsed.scheme != "https" and not (field == "firecrawl_base_url" and parsed.scheme == "http" and parsed.hostname == "searxng")) or not parsed.hostname or parsed.username or parsed.password or parsed.fragment or parsed.query:
raise ValueError("unsafe_provider_url")
urls[field] = value
credentials = payload.get("credentials", {})
+22 -1
View File
@@ -11,7 +11,7 @@ class AIResearchTests(unittest.TestCase):
"AI_RESEARCH_PROVIDER", "AI_RESEARCH_PROVIDER_MODEL", "AI_RESEARCH_PROVIDER_URL",
"AI_RESEARCH_PROVIDER_ALLOWED_HOSTS", "AI_RESEARCH_PROVIDER_API_KEY", "OPENAI_API_KEY",
"NOUS_API_KEY", "NOUS_MODEL", "NOUS_BASE_URL", "NOUS_ALLOWED_HOSTS",
"FIRECRAWL_API_KEY", "FIRECRAWL_BASE_URL", "FIRECRAWL_ALLOWED_HOSTS",
"FIRECRAWL_API_KEY", "FIRECRAWL_BASE_URL", "FIRECRAWL_ALLOWED_HOSTS", "SEARXNG_BASE_URL", "SEARXNG_ALLOWED_HOSTS",
)
def tearDown(self):
@@ -164,6 +164,27 @@ class AIResearchTests(unittest.TestCase):
with self.assertRaisesRegex(AIResearchConfigError, "not_configured"):
research({"keywords": ["solar"]}, 5)
def test_self_hosted_searxng_search_and_native_scrape_are_bounded(self):
self.configure_nous(); os.environ.pop("FIRECRAWL_API_KEY", None)
os.environ.update({"SEARXNG_BASE_URL": "http://searxng:8080", "SEARXNG_ALLOWED_HOSTS": "searxng"})
responses = [
self.response({"choices": [{"message": {"tool_calls": [{"id": "s", "type": "function", "function": {"name": "web_search", "arguments": '{"query":"solar","limit":1}'}}]}}]}),
self.response({"results": [{"title": "Solar", "url": "https://solar.example", "content": "snippet"}]}),
self.response({"choices": [{"message": {"tool_calls": [{"id": "p", "type": "function", "function": {"name": "scrape_website", "arguments": '{"url":"https://solar.example"}'}}]}}]}),
self.response({"choices": [{"message": {"content": '{"targets":[{"url":"https://solar.example"}]}'}}]}),
]
scan = {"status": 200, "final_url": "https://solar.example", "title": "Solar", "meta_description": "", "headings": [], "html": "<script>ignore</script><h1>Solar</h1><p>Public page</p>", "error_code": None}
with patch("app.ai_research.urlopen", side_effect=responses), patch("app.ai_research.validate_url", side_effect=lambda url, **_: url), patch("app.ai_research.scan_website", return_value=scan) as scanner:
self.assertEqual(research({"keywords": ["solar"]}, 3), ["https://solar.example"])
scanner.assert_called_once_with("https://solar.example", max_bytes=16 * 1024)
self.assertEqual(provider_status()["search_provider"], "searxng")
self.assertEqual(provider_status()["scrape_provider"], "native_crawler")
self.assertNotIn("nous-secret", json.dumps(provider_status()))
def test_self_hosted_unsafe_endpoint_fails_closed(self):
self.configure_nous(); os.environ.update({"SEARXNG_BASE_URL": "http://127.0.0.1:8080", "SEARXNG_ALLOWED_HOSTS": "searxng"})
self.assertEqual(provider_status()["status"], "unsafe_provider")
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -121,7 +121,7 @@
<div class="crm-two-col"><article class="panel"><div class="panel-heading"><div><p class="eyebrow">ADD RULE</p><h3>Add suppression</h3></div><span class="small-label">Explicit confirmation required</span></div><form id="suppressionForm" class="crm-form"><label>Kind<select name="kind"><option value="email">Email</option><option value="domain">Domain</option><option value="phone">Phone</option></select></label><label>Value<input name="value" required placeholder="person@example.com"></label><label>Reason <span class="optional">optional</span><input name="reason" placeholder="Customer request / policy"></label><p id="suppressionMessage" class="form-message" role="status"></p><button class="button danger" type="submit">Add suppression</button></form></article><article class="panel"><div class="panel-heading"><div><p class="eyebrow">REGISTER</p><h3>Current suppressions</h3></div><div class="suppression-bulk-actions"><label class="checkbox-label"><input id="selectAllSuppressions" type="checkbox"> Select all</label><button class="button ghost compact" id="bulkReviewSuppressionsBtn" type="button" disabled>Review selected</button></div></div><div id="suppressionState" class="detail-loading">Sign in to load suppressions.</div></article></div>
</section>
<section class="crm-section outreach-settings-section" id="outreachSettings" aria-labelledby="outreachSettingsTitle" data-smoke="outreach-provider-policy"><div class="crm-header panel"><div><p class="eyebrow">GOVERNANCE</p><h2 id="outreachSettingsTitle">Outreach provider policy</h2><p class="muted">View approved provider status without exposing credentials or secrets.</p></div><button class="button ghost" id="outreachPolicyRefreshBtn" type="button">↻ Refresh policy</button></div><div class="outreach-settings-safety" role="note"><strong>Sending is disabled by default.</strong> This panel is status-only. Provider configuration never creates a send trigger, and no credentials are displayed.</div><div id="providerPolicyPanel" class="provider-policy-panel" aria-live="polite"><div class="detail-loading">Sign in to load provider policy.</div></div></section>
<section class="crm-section ai-provider-settings-section" id="aiProviderSettings" aria-labelledby="aiProviderSettingsTitle" data-smoke="ai-provider-settings"><div class="crm-header panel"><div><p class="eyebrow">SYSTEM CONFIGURATION</p><h2 id="aiProviderSettingsTitle">AI Provider Settings</h2><p class="muted">Configure the approved Nous Portal and Firecrawl services used by workspace AI research.</p></div><button class="button ghost" id="aiProviderRefreshBtn" type="button">↻ Refresh status</button></div><div class="ai-provider-safety" role="note"><strong>Admin-only and write-only credentials.</strong> Keys are sent only over the authenticated API, are never displayed or stored in this browser, and updating settings does not start discovery or AI work.</div><div id="aiProviderAdminState" class="provider-state" role="status" hidden></div><div class="ai-provider-grid"><article class="panel ai-provider-form-panel"><div class="panel-heading"><div><p class="eyebrow">PROVIDER CONFIGURATION</p><h3>Service connection</h3></div><span class="small-label">Approved providers only</span></div><form id="aiProviderForm"><div class="form-grid"><label>AI provider<select id="aiProvider" name="provider"><option value="nous_portal">Nous Portal</option></select></label><label>Nous Portal model<input id="nousModel" name="nous_model" required maxlength="160" placeholder="Hermes 4 405B" autocomplete="off"></label><label>Nous Portal base URL<input id="nousBaseUrl" name="nous_base_url" type="url" required placeholder="https://inference-api.nousresearch.com/v1" autocomplete="off"></label><label>Nous Portal API key <span class="optional">write-only · leave blank to keep</span><input id="nousApiKey" name="nous_api_key" type="password" maxlength="512" placeholder="Enter a new key to rotate" autocomplete="new-password"></label><label>Firecrawl base URL<input id="firecrawlBaseUrl" name="firecrawl_base_url" type="url" required placeholder="https://api.firecrawl.dev/v1" autocomplete="off"></label><label>Firecrawl API key <span class="optional">write-only · leave blank to keep</span><input id="firecrawlApiKey" name="firecrawl_api_key" type="password" maxlength="512" placeholder="Enter a new key to rotate" autocomplete="new-password"></label></div><div class="form-footer"><p id="aiProviderMessage" class="form-message" role="status" aria-live="polite"></p><button class="button primary" id="saveAiProviderBtn" type="submit">Save provider settings</button></div></form></article><aside class="panel ai-provider-status-panel"><div class="panel-heading"><div><p class="eyebrow">SAFE STATUS</p><h3>Connection status</h3></div><button class="button ghost compact" id="testAiProviderBtn" type="button">Test connection</button></div><div id="aiProviderStatus" aria-live="polite"><div class="detail-loading">Sign in to load provider status.</div></div></aside></div></section>
<section class="crm-section ai-provider-settings-section" id="aiProviderSettings" aria-labelledby="aiProviderSettingsTitle" data-smoke="ai-provider-settings"><div class="crm-header panel"><div><p class="eyebrow">SYSTEM CONFIGURATION</p><h2 id="aiProviderSettingsTitle">AI Provider Settings</h2><p class="muted">Configure the approved Nous Portal service and the internal SearXNG service used by workspace AI research.</p></div><button class="button ghost" id="aiProviderRefreshBtn" type="button">↻ Refresh status</button></div><div class="ai-provider-safety" role="note"><strong>Admin-only and write-only credentials.</strong> Keys are sent only over the authenticated API, are never displayed or stored in this browser, and updating settings does not start discovery or AI work.</div><div id="aiProviderAdminState" class="provider-state" role="status" hidden></div><div class="ai-provider-grid"><article class="panel ai-provider-form-panel"><div class="panel-heading"><div><p class="eyebrow">PROVIDER CONFIGURATION</p><h3>Service connection</h3></div><span class="small-label">Approved providers only</span></div><form id="aiProviderForm"><div class="form-grid"><label>AI provider<select id="aiProvider" name="provider"><option value="nous_portal">Nous Portal</option></select></label><label>Nous Portal model<input id="nousModel" name="nous_model" required maxlength="160" placeholder="Hermes 4 405B" autocomplete="off"></label><label>Nous Portal base URL<input id="nousBaseUrl" name="nous_base_url" type="url" required placeholder="https://inference-api.nousresearch.com/v1" autocomplete="off"></label><label>Nous Portal API key <span class="optional">write-only · leave blank to keep</span><input id="nousApiKey" name="nous_api_key" type="password" maxlength="512" placeholder="Enter a new key to rotate" autocomplete="new-password"></label><label>SearXNG base URL<input id="firecrawlBaseUrl" name="firecrawl_base_url" type="url" required placeholder="https://api.firecrawl.dev/v1" autocomplete="off"></label><label>Firecrawl API key <span class="optional">write-only · leave blank to keep</span><input id="firecrawlApiKey" name="firecrawl_api_key" type="password" maxlength="512" placeholder="Enter a new key to rotate" autocomplete="new-password"></label></div><div class="form-footer"><p id="aiProviderMessage" class="form-message" role="status" aria-live="polite"></p><button class="button primary" id="saveAiProviderBtn" type="submit">Save provider settings</button></div></form></article><aside class="panel ai-provider-status-panel"><div class="panel-heading"><div><p class="eyebrow">SAFE STATUS</p><h3>Connection status</h3></div><button class="button ghost compact" id="testAiProviderBtn" type="button">Test connection</button></div><div id="aiProviderStatus" aria-live="polite"><div class="detail-loading">Sign in to load provider status.</div></div></aside></div></section>
<footer><span>ProspectOS</span><span>Evidence-led prospecting · <a href="#explorer">Review queue</a></span></footer>
</div>
</main>
+31
View File
@@ -25,6 +25,8 @@ services:
NOUS_MODEL: ${NOUS_MODEL:-Hermes-4-405B}
NOUS_BASE_URL: ${NOUS_BASE_URL:-https://inference-api.nousresearch.com/v1}
NOUS_ALLOWED_HOSTS: ${NOUS_ALLOWED_HOSTS:-inference-api.nousresearch.com}
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-http://searxng:8080}
SEARXNG_ALLOWED_HOSTS: ${SEARXNG_ALLOWED_HOSTS:-searxng}
FIRECRAWL_API_KEY: ${FIRECRAWL_API_KEY:-}
FIRECRAWL_BASE_URL: ${FIRECRAWL_BASE_URL:-https://api.firecrawl.dev/v2}
FIRECRAWL_ALLOWED_HOSTS: ${FIRECRAWL_ALLOWED_HOSTS:-api.firecrawl.dev}
@@ -52,6 +54,8 @@ services:
retries: 5
start_period: 5s
restart: unless-stopped
networks:
- prospect_internal
web:
build:
@@ -80,7 +84,34 @@ services:
retries: 5
start_period: 3s
restart: unless-stopped
networks:
- prospect_internal
searxng:
image: searxng/searxng:latest
environment:
SEARXNG_SECRET_KEY: ${SEARXNG_SECRET_KEY:-change-me-in-production}
volumes:
- ./searxng/settings.yml:/etc/searxng/settings.yml:ro
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
expose:
- "8080"
networks:
- prospect_internal
- searxng_egress
restart: unless-stopped
volumes:
prospect_api_data:
name: prospect-platform-api-data
networks:
prospect_internal:
internal: true
searxng_egress:
+17 -14
View File
@@ -17,8 +17,7 @@ After the first successful login, remove both values from `.env`, restart the AP
Copy `.env.example` to an untracked deployment environment file. Production requires a secret-manager supplied `SESSION_SECRET` of at least 32 characters and refuses `AUTOMATED_OUTREACH_ENABLED=true`. Keep bootstrap credentials one-time only; remove and rotate them after provisioning. Never place secrets in images, Compose YAML, logs, backups, or public web roots.
For optional native Nous Portal Chat Completions tool-calling discovery, set these
server-side variables:
For criteria-first AI web research without a paid scraper, use the self-hosted mode:
```dotenv
AI_RESEARCH_PROVIDER=nous_portal
@@ -26,20 +25,24 @@ NOUS_API_KEY=<Nous Portal API key>
NOUS_MODEL=Hermes-4-405B
NOUS_BASE_URL=https://inference-api.nousresearch.com/v1
NOUS_ALLOWED_HOSTS=inference-api.nousresearch.com
FIRECRAWL_API_KEY=<Firecrawl API key>
FIRECRAWL_BASE_URL=https://api.firecrawl.dev/v2
FIRECRAWL_ALLOWED_HOSTS=api.firecrawl.dev
SEARXNG_BASE_URL=http://searxng:8080
SEARXNG_ALLOWED_HOSTS=searxng
SEARXNG_SECRET_KEY=<random secret>
```
The adapter calls Nous at `/chat/completions` with strict `web_search` and
`scrape_website` function tools. Tool calls are executed only against the
allowlisted Firecrawl-compatible API, capped at 4 calls and 16 KiB per tool
result. Prompt-injection-shaped criteria are rejected and tool/page content is
untrusted data. The final model response is parsed only as structured JSON
HTTPS targets; the existing crawler performs SSRF validation and persists
fetched-page evidence. Missing either key, unavailable providers, unsafe base
URLs, malformed tool calls, oversized responses, and exhausted budgets fail
closed. Status metadata never includes secrets.
Compose starts SearXNG without publishing a host port. The API reaches it only
on the internal Compose network; SearXNG alone has a separate egress network for
its upstream search engines. `web_search` calls SearXNG's JSON endpoint and
`scrape_website` uses the API's bounded SSRF-safe scanner. No Firecrawl key is
required. Firecrawl variables remain an optional legacy compatibility fallback.
The Nous adapter calls `/chat/completions` with strict `web_search` and
`scrape_website` function tools. Tool calls are capped at 4 and each result at
16 KiB; criteria, model output, page text, redirects, and candidate URLs remain
bounded and untrusted. Only structured HTTPS targets are accepted and the
existing crawler fetches/persists evidence. Missing/unsafe configuration,
unavailable providers, malformed calls, oversized responses, SSRF targets, and
exhausted budgets fail closed. Status metadata never includes secrets.
The prior OpenAI Responses and generic provider variables remain supported only
as compatibility adapters.
+26
View File
@@ -0,0 +1,26 @@
use_default_settings: true
server:
bind_address: 0.0.0.0
port: 8080
secret_key: "${SEARXNG_SECRET_KEY}"
limiter: true
image_proxy: false
public_instance: false
search:
formats:
- html
- json
ui:
static_use_hash: true
outgoing:
request_timeout: 8.0
max_request_timeout: 10.0
retries: 0
categories_as_tabs:
general:
engines:
- google
- bing
- duckduckgo
- brave
- wikipedia