diff --git a/.gitignore b/.gitignore index 350887f..dee2fc6 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,7 @@ data_bench/ # Test stack (docker-compose.test.yml): separate host data dir for test ingests data_test/ + +# Hermes sandbox (integrations/hermes/sandbox): throwaway agent profile dir — +# holds a .env with the LLM key + provider state; never commit it. +integrations/hermes/sandbox/hermes-data/ diff --git a/CLAUDE.md b/CLAUDE.md index 0e780fb..d4567dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ braindb/ │ ├── config.py # Settings (decay rates, graph params, agent config) │ ├── db.py # get_conn() — one psycopg2 connection per request │ ├── ingest_watcher.py # Always-on sidecar: polls data/sources/, triggers agent -│ ├── routers/ # entities.py, relations.py, memory.py, agent.py +│ ├── routers/ # entities.py, relations.py, memory.py, agent.py, wiki.py, integrations.py │ ├── schemas/ # Pydantic models (entities, relations, search) │ ├── services/ # search.py, graph.py, context.py, embedding_service.py, │ │ # keyword_service.py, activity_log.py @@ -88,6 +88,7 @@ braindb/ │ ├── braindb/SKILL.md # Direct curl-based skill │ └── braindb-agent/SKILL.md # Thin wrapper around the agent endpoint ├── frontend/ # Read-only browser UI — vanilla JS, no build (served at :8642) +├── integrations/hermes/ # BrainDB-as-memory for Hermes Agent (provider plugin + test sandbox) ├── docker-compose.yml # api + watcher + wiki_scheduler + frontend (+ optional bundled Postgres) ├── .env # Real credentials — DO NOT COMMIT └── BRAINDB_GUIDE.md # Full API reference with curl examples diff --git a/README.md b/README.md index 6eb48ce..434356e 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,10 @@ If you'd rather have BrainDB always running (even before any skill is invoked), Replace `/ABSOLUTE/PATH/TO/braindb` with your repo path. The hook is async (non-blocking). +## Integrations + +Use BrainDB as the memory backend for other agents. **Hermes Agent** (Nous Research) has a ready-made provider in [`integrations/hermes/`](integrations/hermes/): a native memory-provider plugin ([`braindb/`](integrations/hermes/braindb/)) that gives the agent a `braindb_ask` gateway plus a `braindb_ingest` tool, and a hardened, tool-stripped Docker sandbox ([`sandbox/`](integrations/hermes/sandbox/)) for safely trying it on a throwaway host. Each folder's README has the setup. + ## File Ingestion Drop a file in `data/sources/` — the always-on watcher sidecar picks it up within 7s, ingests it, and runs a chunked fact-extraction pipeline that saves atomic facts into the knowledge graph linked back to the source via `derived_from` relations. Processed files move to `data/sources/ingested/`, failures to `data/sources/failed/` with an `.error.txt` sidecar. diff --git a/braindb/main.py b/braindb/main.py index 21040e5..ff47f77 100644 --- a/braindb/main.py +++ b/braindb/main.py @@ -3,7 +3,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from braindb.routers import agent, entities, memory, relations, wiki +from braindb.routers import agent, entities, integrations, memory, relations, wiki from braindb.services.embedding_service import get_embedding_service logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") @@ -26,6 +26,9 @@ app.include_router(memory.router) app.include_router(agent.router) app.include_router(wiki.router) +# External-integration endpoints (Hermes memory provider + similar clients). +# Additive only; see braindb/routers/integrations.py. +app.include_router(integrations.router) @app.on_event("startup") diff --git a/braindb/routers/integrations.py b/braindb/routers/integrations.py new file mode 100644 index 0000000..4212e0f --- /dev/null +++ b/braindb/routers/integrations.py @@ -0,0 +1,93 @@ +""" +External-integration endpoints — consumed by the Hermes memory provider (see +`integrations/hermes/`) and similar external clients. These are NOT used by +BrainDB's own app, agent, or watcher; they exist purely so an outside agent can +(a) self-configure from BrainDB's shipped skill text (single source of truth, no +copied prompt) and (b) hand a file to BrainDB's existing ingestion pipeline over +HTTP without needing a shared filesystem. + +Kept isolated in this dedicated router (and registered with a single +`include_router` line in main.py) so the integration surface is obvious and the +core routers stay untouched. Everything here is additive — no existing route, +schema, or behaviour is affected. +""" +import os +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import PlainTextResponse + +router = APIRouter(prefix="/api/v1", tags=["integrations"]) + +# Mirror the watcher's watch dir + allow-list so an upload lands exactly where +# the existing ingestion pipeline (braindb/ingest_watcher.py) already looks. +_WATCH_DIR = Path(os.getenv("INGEST_WATCH_DIR", "data/sources")) +_ALLOWED_EXTS = {".md", ".txt", ".json", ".yaml", ".yml", ".csv", ".log", ".html", ".xml"} +_MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB — generous for text docs, blocks abuse +_SKILLS_DIR = Path("skills") + + +def _safe_basename(raw: str) -> str: + """Reduce a client-supplied name to a bare filename — no path traversal. + + We only ever join the basename to a fixed directory, so traversal is + impossible even before this check; the explicit guard just rejects obvious + junk early with a clear 400. + """ + base = os.path.basename((raw or "").replace("\\", "/").strip()) + if not base or base in (".", "..") or base.startswith("."): + raise HTTPException(400, "Invalid or unsafe name") + return base + + +@router.get("/skill/{name}", response_class=PlainTextResponse) +def get_skill(name: str) -> str: + """Serve a shipped skill's markdown (e.g. `name=braindb-agent`). + + Lets an external client load its usage instructions live from BrainDB rather + than bundling a copy that could drift. Read-only. + """ + safe = _safe_basename(name) + path = _SKILLS_DIR / safe / "SKILL.md" + if not path.is_file(): + raise HTTPException(404, f"No skill named {safe!r}") + return path.read_text(encoding="utf-8") + + +@router.post("/entities/datasources/upload", status_code=202) +async def upload_datasource(request: Request, filename: str): + """Accept a file (raw request body) and drop it into `data/sources/` so the + existing watcher ingests + fact-extracts it. + + Adds NO ingestion logic of its own — it only lands the bytes where the + pipeline already polls. Extraction is asynchronous (the watcher picks the + file up on its next tick). Raw body + `filename` query param is used + deliberately so this needs no multipart dependency. + """ + fname = _safe_basename(filename) + ext = Path(fname).suffix.lower() + if ext not in _ALLOWED_EXTS: + raise HTTPException(400, f"Unsupported extension {ext!r}; allowed: {sorted(_ALLOWED_EXTS)}") + + data = await request.body() + if not data: + raise HTTPException(400, "Empty body") + if len(data) > _MAX_UPLOAD_BYTES: + raise HTTPException(413, f"File too large (> {_MAX_UPLOAD_BYTES} bytes)") + + _WATCH_DIR.mkdir(parents=True, exist_ok=True) + dest = _WATCH_DIR / fname + # No-overwrite on name collision (mirrors the watcher's own dedup). + if dest.exists(): + stem, suffix = dest.stem, dest.suffix + i = 1 + while (cand := _WATCH_DIR / f"{stem}.{i}{suffix}").exists(): + i += 1 + dest = cand + dest.write_bytes(data) + + return { + "filename": dest.name, + "status": "accepted", + "message": "Uploaded to data/sources/; the watcher will ingest and fact-extract it shortly.", + } diff --git a/integrations/hermes/braindb/README.md b/integrations/hermes/braindb/README.md new file mode 100644 index 0000000..cb33462 --- /dev/null +++ b/integrations/hermes/braindb/README.md @@ -0,0 +1,65 @@ +# BrainDB memory provider for Hermes Agent + +Use a self-hosted [BrainDB](https://github.com/dimknaf/braindb) as the long-term +memory backend for [Hermes Agent](https://github.com/NousResearch/hermes-agent). + +It's a small native Hermes `MemoryProvider`: the Hermes agent gets two tools that +talk to a running BrainDB over HTTP. Nothing about BrainDB's API is re-implemented +here — the gateway tool delegates to BrainDB's own agent, which already wields its +full toolset. + +## Install + +Hermes discovers memory providers as folders under `~/.hermes/plugins/`. Copy this +plugin folder there (user plugins live FLAT under `plugins//`): + +```bash +cp -r integrations/hermes/braindb ~/.hermes/plugins/braindb +``` + +Then activate it: + +```bash +hermes memory setup # pick "braindb" +``` + +## Configure + +One setting, resolved in this order: `BRAINDB_BASE_URL` env var → +`$HERMES_HOME/braindb.json` (`{"base_url": "..."}`) → default `http://localhost:8000`. + +```bash +export BRAINDB_BASE_URL=http://localhost:8000 +``` + +## Tools the agent gets + +- **`braindb_ask(query)`** — ask BrainDB in natural language to recall or remember. + This is the gateway to *all* of BrainDB: its internal agent does the search, + storage, graph-linking and reasoning, and returns an answer. +- **`braindb_ingest(file_path)`** — upload a local text file to BrainDB. BrainDB + ingests and **fact-extracts it asynchronously**, so the extracted facts show up + on a *later* `braindb_ask` recall, not immediately. Supported extensions: + `.md .txt .json .yaml .yml .csv .log .html .xml`. + +Usage instructions for the agent are loaded **live** from BrainDB's +`/api/v1/skill/braindb-agent` endpoint (single source of truth, cached locally), +so there's no duplicated prompt to keep in sync. + +## Deployment notes + +- **Shared store, single user.** All memory is one BrainDB instance; this provider + does no per-user scoping. +- **BrainDB has no authentication.** Run Hermes and BrainDB **co-located**, with + BrainDB on loopback (`localhost`). For a *remote* BrainDB you must put your own + auth / tunnel in front of it — that's out of scope here. +- The provider makes all calls over HTTP itself; it never shells out via the + agent's terminal, so it works regardless of Hermes' terminal sandbox. + +## Requires on the BrainDB side + +Two small additive endpoints (shipped with BrainDB in +`braindb/routers/integrations.py`): `GET /api/v1/skill/{name}` (serves the skill +text) and `POST /api/v1/entities/datasources/upload` (lands a file in +`data/sources/` for the watcher to ingest). The BrainDB **watcher** must be +running for ingestion to happen (it is by default in `docker-compose`). diff --git a/integrations/hermes/braindb/__init__.py b/integrations/hermes/braindb/__init__.py new file mode 100644 index 0000000..4a89cec --- /dev/null +++ b/integrations/hermes/braindb/__init__.py @@ -0,0 +1,267 @@ +""" +BrainDB memory provider for Hermes Agent (https://github.com/NousResearch/hermes-agent). + +Install: drop this `braindb/` folder into `~/.hermes/plugins/braindb/` (user +plugins live FLAT under plugins//), then `hermes memory setup` -> "braindb". + +Design (deliberately small): +- AGENT-ONLY gateway. `braindb_ask` forwards a natural-language request to + BrainDB's own `/agent/query`, whose internal agent wields BrainDB's full + toolset — so the *whole* of BrainDB (recall / save / relate / reason) is + reachable through one tool, with zero duplication of its logic here. +- `braindb_ingest` uploads a local file to BrainDB; BrainDB's watcher then + ingests + fact-extracts it asynchronously. +- Usage instructions are loaded LIVE from BrainDB's `/skill` endpoint (single + source of truth, cached locally) — no copied/duplicated prompt — plus a small + Hermes-specific note. + +The provider makes all HTTP calls itself (via `requests`); it never relies on +the agent's shell/terminal. Co-locate Hermes and BrainDB (BrainDB has no auth — +keep it on loopback or behind your own auth/tunnel for remote use). +""" +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path + +import requests + +from agent.memory_provider import MemoryProvider + +logger = logging.getLogger(__name__) + +_DEFAULT_BASE_URL = "http://localhost:8000" +_SKILL_NAME = "braindb-agent" +_ASK_TIMEOUT = 90 # /agent/query runs an LLM loop — allow headroom +_HTTP_TIMEOUT = 15 # skill fetch / file upload +_HEALTH_TIMEOUT = 2 # readiness ping + +# Last-resort instructions, used ONLY if BrainDB is unreachable AND no cached +# skill exists. The live skill (loaded in initialize) is the real source. +_BUILTIN_INSTRUCTIONS = ( + "BrainDB is your persistent memory. Use `braindb_ask` with a natural-language " + "request to recall facts/context or to remember something. Use `braindb_ingest` " + "with a local file path to add a document to memory." +) + +# Hermes-specific addendum — net-new glue, NOT a copy of the skill. +_HERMES_NOTES = ( + "\n\n## Using BrainDB from Hermes\n" + "- `braindb_ask(query)` is the main interface: ask in plain language to recall or " + "save; BrainDB's own agent does the work and returns an answer.\n" + "- `braindb_ingest(file_path)` uploads a local file to BrainDB; ingestion + fact " + "extraction run asynchronously, so new facts appear on a *later* `braindb_ask` " + "recall, not immediately.\n" +) + +_ASK_SCHEMA = { + "name": "braindb_ask", + "description": ( + "Query your BrainDB persistent memory in natural language. Use it to RECALL " + "(facts, context, past decisions — anything you may have stored) and to REMEMBER " + "(save a new fact/thought, connect ideas). BrainDB's own agent handles search, " + "storage, graph links and reasoning, and returns a written answer." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": ( + "A natural-language question or instruction, e.g. 'what do we know " + "about project X?' or 'remember that the deadline is Friday'." + ), + }, + }, + "required": ["query"], + }, +} + +_INGEST_SCHEMA = { + "name": "braindb_ingest", + "description": ( + "Add a local document to BrainDB memory. Provide the path to a text file on this " + "machine; it is uploaded to BrainDB, which ingests it and extracts facts in the " + "background — so the extracted facts become available on a LATER braindb_ask " + "recall, not instantly. Supported: .md .txt .json .yaml .yml .csv .log .html .xml." + ), + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Absolute or relative path to a local file to ingest.", + }, + }, + "required": ["file_path"], + }, +} + + +class BrainDBMemoryProvider(MemoryProvider): + """Exposes a self-hosted BrainDB instance to Hermes as a memory provider.""" + + def __init__(self) -> None: + self._base_url = _DEFAULT_BASE_URL + self._instructions = _BUILTIN_INSTRUCTIONS + self._hermes_home = "" + + @property + def name(self) -> str: + return "braindb" + + def is_available(self) -> bool: + """True only if a BrainDB is actually answering — so Hermes won't + activate a dead provider. Bounded by a short timeout.""" + try: + r = requests.get(f"{self._resolve_base_url()}/health", timeout=_HEALTH_TIMEOUT) + return r.status_code == 200 + except requests.RequestException: + return False + + def initialize(self, session_id: str, **kwargs) -> None: + self._hermes_home = kwargs.get("hermes_home", "") or "" + self._base_url = self._resolve_base_url() + self._instructions = self._load_instructions() + + def get_config_schema(self): + return [ + { + "key": "base_url", + "description": "BrainDB base URL (the running BrainDB API).", + "default": _DEFAULT_BASE_URL, + "env_var": "BRAINDB_BASE_URL", + }, + ] + + def save_config(self, values, hermes_home: str) -> None: + try: + data = {k: v for k, v in (values or {}).items() if v is not None} + (Path(hermes_home) / "braindb.json").write_text( + json.dumps(data, indent=2), encoding="utf-8" + ) + except Exception as e: # config persistence is best-effort + logger.warning("braindb: could not write config: %s", e) + + def system_prompt_block(self) -> str: + return f"{self._instructions}{_HERMES_NOTES}" + + def get_tool_schemas(self): + return [_ASK_SCHEMA, _INGEST_SCHEMA] + + def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str: + try: + if tool_name == "braindb_ask": + return self._ask(args) + if tool_name == "braindb_ingest": + return self._ingest(args) + return _err(f"Unknown tool: {tool_name}") + except Exception as e: # a tool call must never crash the turn + logger.exception("braindb: tool %s failed", tool_name) + return _err(str(e)) + + # ---- internals ------------------------------------------------------- + + def _resolve_base_url(self) -> str: + """env BRAINDB_BASE_URL → $HERMES_HOME/braindb.json → default.""" + env = os.environ.get("BRAINDB_BASE_URL") + if env: + return env.rstrip("/") + home = self._hermes_home or os.environ.get("HERMES_HOME", "") + if home: + cfg = Path(home) / "braindb.json" + if cfg.is_file(): + try: + val = json.loads(cfg.read_text(encoding="utf-8")).get("base_url") + if val: + return str(val).rstrip("/") + except Exception: + pass + return _DEFAULT_BASE_URL + + def _cache_path(self) -> Path: + home = self._hermes_home or os.environ.get( + "HERMES_HOME", str(Path.home() / ".hermes") + ) + return Path(home) / "braindb_skill.md" + + def _load_instructions(self) -> str: + """Single source of truth: fetch the skill from BrainDB, cache it so a + transient outage still has the last copy, fall back to a built-in line. + Only needed when BrainDB is up (the tools call BrainDB anyway), so this + introduces no new failure mode.""" + try: + r = requests.get( + f"{self._base_url}/api/v1/skill/{_SKILL_NAME}", timeout=_HTTP_TIMEOUT + ) + if r.status_code == 200 and r.text.strip(): + try: + self._cache_path().write_text(r.text, encoding="utf-8") + except Exception: + pass + return r.text + except requests.RequestException: + pass + try: + cached = self._cache_path() + if cached.is_file(): + return cached.read_text(encoding="utf-8") + except Exception: + pass + return _BUILTIN_INSTRUCTIONS + + def _ask(self, args: dict) -> str: + query = (args or {}).get("query", "").strip() + if not query: + return _err("Missing required parameter: query") + try: + r = requests.post( + f"{self._base_url}/api/v1/agent/query", + json={"query": query}, + timeout=_ASK_TIMEOUT, + ) + r.raise_for_status() + except requests.Timeout: + return _err(f"BrainDB agent timed out after {_ASK_TIMEOUT}s; try a simpler query.") + except requests.RequestException as e: + return _err(f"BrainDB request failed: {e}") + answer = (r.json() or {}).get("answer", "") + return json.dumps({"answer": answer}) + + def _ingest(self, args: dict) -> str: + raw = (args or {}).get("file_path", "").strip() + if not raw: + return _err("Missing required parameter: file_path") + path = Path(raw).expanduser() + if not path.is_file(): + return _err(f"File not found: {raw}") + try: + data = path.read_bytes() + r = requests.post( + f"{self._base_url}/api/v1/entities/datasources/upload", + params={"filename": path.name}, + data=data, + headers={"Content-Type": "application/octet-stream"}, + timeout=_HTTP_TIMEOUT, + ) + r.raise_for_status() + except requests.RequestException as e: + return _err(f"BrainDB upload failed: {e}") + body = r.json() if r.content else {} + return json.dumps({ + "status": "uploaded", + "filename": body.get("filename", path.name), + "message": "Uploaded; BrainDB will ingest and fact-extract it in the background.", + }) + + +def _err(message: str) -> str: + """Tool-error contract: a JSON string (never a raised exception).""" + return json.dumps({"error": message}) + + +def register(ctx) -> None: + """Hermes plugin entry point.""" + ctx.register_memory_provider(BrainDBMemoryProvider()) diff --git a/integrations/hermes/braindb/plugin.yaml b/integrations/hermes/braindb/plugin.yaml new file mode 100644 index 0000000..42e46c3 --- /dev/null +++ b/integrations/hermes/braindb/plugin.yaml @@ -0,0 +1,5 @@ +name: braindb +version: 1.0.0 +description: "BrainDB — self-hosted memory: natural-language recall/save via its own agent, plus file ingestion. Talks to a running BrainDB over HTTP." +pip_dependencies: + - requests diff --git a/integrations/hermes/sandbox/README.md b/integrations/hermes/sandbox/README.md new file mode 100644 index 0000000..29cea7b --- /dev/null +++ b/integrations/hermes/sandbox/README.md @@ -0,0 +1,86 @@ +# Hermes ↔ BrainDB sandbox (run it on an isolated throwaway host) + +A self-contained way to actually run **Hermes Agent** against BrainDB and test the +[`braindb` memory provider](../braindb/) — **safely**. + +## ⚠ Why a throwaway host + +Hermes is an autonomous agent: by default its `terminal` / `code_execution` / browser tools +run **on the host**, and its docs say plainly *"the only security boundary against an +adversarial LLM is the OS."* So, defense in depth: + +1. We **strip** the dangerous toolsets in `config.yaml` — the agent can only use `braindb_*`. +2. We run Hermes **in a container**, as a non-root UID. (We don't `cap_drop` everything — the + image's s6 init needs `CHOWN`/`SETUID`/`SETGID`; that's why the host isolation matters most.) +3. Most importantly, we run the whole thing on a **throwaway, low-trust host** (a Raspberry + Pi) — never a work laptop. If Hermes misbehaves, the blast radius is the Pi. + +We also run a **separate, throwaway BrainDB** here — not your real personal one — so the test +never pollutes real memory or re-exposes an unauthenticated service. + +## What runs + +- **BrainDB** (api + bundled Postgres + watcher) via BrainDB's own compose, `internal-db` + profile, `LLM_PROFILE=deepinfra`, on the `local-network` docker network. +- **Hermes** (`nousresearch/hermes-agent`, this folder's compose) on the same network, + reaching BrainDB at `http://braindb_api:8000`, brain on **deepinfra**. + +Both LLMs are on deepinfra — **the workstation is never touched.** + +## Deploy (on the Pi) + +```bash +cd ~/braindb +docker network create local-network 2>/dev/null || true + +# 1) BrainDB env (repo-root .env): bundled DB + deepinfra + wiki off +cat > .env <<'EOF' +COMPOSE_PROFILES=internal-db +LLM_PROFILE=deepinfra +DEEPINFRA_API_KEY= +WIKI_ENABLED=false +EOF + +# 2) Build + start BrainDB (first arm64 build is slow; downloads the embedding model) +docker compose up -d --build api watcher braindb_db +curl -s http://localhost:8000/health # {"status":"ok","embeddings":true} + +# 3) Hermes profile: copy the template, then set YOUR model + key in it (gitignored) +cd integrations/hermes/sandbox +mkdir -p hermes-data +cp config.yaml hermes-data/config.yaml +# edit hermes-data/config.yaml -> model.default: =64k-context model id> +# model.api_key: + +# 4) Start Hermes +docker compose -f docker-compose.hermes-test.yml up -d +``` + +## Verify + +```bash +docker exec hermes_test curl -s http://braindb_api:8000/health # Hermes can reach BrainDB +docker exec hermes_test hermes memory status # Provider: braindb / installed + active + +# Talk to it (-q = single non-interactive query). It should call braindb_ask. +docker exec hermes_test hermes chat -q "Remember that my favourite colour is teal, then tell me what you stored." + +# Ingest a local file -> braindb_ingest -> BrainDB watcher extracts (async) +docker exec hermes_test sh -c 'echo "Project Falcon ships on Friday." > /tmp/note.md' +docker exec hermes_test hermes chat -q "Ingest the file at /tmp/note.md into memory." +``` + +## Teardown + +```bash +docker compose -f docker-compose.hermes-test.yml down -v && rm -rf hermes-data +docker compose down -v # at the repo root, to drop the throwaway BrainDB +``` + +## Notes + +- **Model:** Hermes refuses models under 64k context; set a ≥64k model in `config.yaml`. +- **Plugin path is FLAT:** the provider is mounted at `/opt/data/plugins/braindb` (= user + plugins live at `~/.hermes/plugins//`, not under a `memory/` subdir). +- Remote BrainDB would need auth in front of it; here everything is co-located on one + isolated host, so loopback / internal-network access is fine. diff --git a/integrations/hermes/sandbox/config.yaml b/integrations/hermes/sandbox/config.yaml new file mode 100644 index 0000000..ddebf24 --- /dev/null +++ b/integrations/hermes/sandbox/config.yaml @@ -0,0 +1,29 @@ +# Hermes Agent config for the BrainDB sandbox (TEMPLATE — throwaway profile). +# Copied to ./hermes-data/config.yaml at deploy; edit THAT copy (gitignored) with +# your real model id + deepinfra key. Nothing secret is committed here. +# +# Brain : deepinfra via a custom OpenAI-compatible endpoint. +# Memory : the braindb provider (its braindb_ask / braindb_ingest tools). +# Safety : every dangerous toolset is STRIPPED below, so the agent can only reach +# BrainDB — it cannot run shell, code, the browser, or write files. + +model: + provider: custom + base_url: https://api.deepinfra.com/v1/openai + # Fill these in your gitignored ./hermes-data/config.yaml (NEVER commit them): + default: "" # a model id with >=64k context (Hermes refuses anything smaller) + api_key: "" # your deepinfra key (the custom provider reads model.api_key) + +memory: + provider: braindb + +agent: + # Strip everything that could touch the host or the open internet. The braindb + # provider's tools are NOT part of these toolsets, so they stay available. + disabled_toolsets: + - terminal # arbitrary shell on the host + - code_execution # arbitrary Python (toolset is 'code_execution', not 'execute_code') + - browser # web automation + - computer_use # desktop control + - web # web_search / web_extract (network egress) + - file # write_file / patch (filesystem writes) diff --git a/integrations/hermes/sandbox/docker-compose.hermes-test.yml b/integrations/hermes/sandbox/docker-compose.hermes-test.yml new file mode 100644 index 0000000..7f56404 --- /dev/null +++ b/integrations/hermes/sandbox/docker-compose.hermes-test.yml @@ -0,0 +1,30 @@ +# Hermes Agent sandbox — runs Hermes in a TOOL-STRIPPED container (see +# ./hermes-data/config.yaml: agent.disabled_toolsets) joined to BrainDB's docker +# network so it reaches the API at http://braindb_api:8000. +# +# For an ISOLATED, THROWAWAY host (a Raspberry Pi) — NOT a work laptop. The host +# isolation + the tool-stripping ARE the security boundary. We do NOT cap_drop: +# the image's s6-overlay init needs CHOWN/SETUID/SETGID to set up its own +# unprivileged user and /opt/data (matches the official Hermes compose); instead +# we run as a non-root UID. +# +# Bring BrainDB up first (its own compose, COMPOSE_PROFILES=internal-db), then this. +services: + hermes: + image: nousresearch/hermes-agent:latest # multi-arch (incl. arm64 for the Pi) + container_name: hermes_test + restart: unless-stopped + networks: + - local-network + environment: + BRAINDB_BASE_URL: http://braindb_api:8000 # the braindb memory provider reads this + HERMES_UID: "1000" # run as the host user that owns ./hermes-data + HERMES_GID: "1000" + volumes: + - ./hermes-data:/opt/data # throwaway profile (config.yaml with your model + key) + - ../braindb:/opt/data/plugins/braindb:ro # the BrainDB plugin (FLAT user-plugin path: plugins//) + command: gateway run +networks: + # BrainDB already declares this as an external network; we join the same one. + local-network: + external: true diff --git a/tests/test_integrations.py b/tests/test_integrations.py new file mode 100644 index 0000000..a631afb --- /dev/null +++ b/tests/test_integrations.py @@ -0,0 +1,66 @@ +""" +External-integration endpoints (the Hermes memory-provider surface): + GET /api/v1/skill/{name} + POST /api/v1/entities/datasources/upload + +Runs against the isolated test stack (the `api` fixture). That stack has NO +watcher and uses the throwaway ./data_test mount, so uploads here never trigger +ingestion (no LLM) and never touch the personal stack. +""" +import requests + +SKILL = "/api/v1/skill" +UPLOAD = "/api/v1/entities/datasources/upload" + + +def test_skill_served(api): + r = requests.get(f"{api}{SKILL}/braindb-agent", timeout=10) + assert r.status_code == 200 + assert r.text.strip() # non-empty markdown body + + +def test_skill_unknown_404(api): + r = requests.get(f"{api}{SKILL}/no-such-skill", timeout=10) + assert r.status_code == 404 + + +def test_skill_unsafe_name_400(api): + # leading dot -> rejected by _safe_basename before any disk access + r = requests.get(f"{api}{SKILL}/.hidden", timeout=10) + assert r.status_code == 400 + + +def test_upload_accepted(api, test_tag): + name = f"{test_tag}.md" + r = requests.post( + f"{api}{UPLOAD}", + params={"filename": name}, + data=b"# note\n\nbody text", + headers={"Content-Type": "application/octet-stream"}, + timeout=10, + ) + assert r.status_code == 202, r.text + assert r.json()["filename"] == name + + +def test_upload_bad_extension_400(api, test_tag): + r = requests.post( + f"{api}{UPLOAD}", params={"filename": f"{test_tag}.exe"}, data=b"x", timeout=10 + ) + assert r.status_code == 400 + + +def test_upload_empty_400(api, test_tag): + r = requests.post( + f"{api}{UPLOAD}", params={"filename": f"{test_tag}.md"}, data=b"", timeout=10 + ) + assert r.status_code == 400 + + +def test_upload_collision_no_overwrite(api, test_tag): + name = f"{test_tag}_c.md" + first = requests.post(f"{api}{UPLOAD}", params={"filename": name}, data=b"one", timeout=10) + second = requests.post(f"{api}{UPLOAD}", params={"filename": name}, data=b"two", timeout=10) + assert first.status_code == 202 and second.status_code == 202 + assert first.json()["filename"] == name + assert second.json()["filename"] == f"{test_tag}_c.1.md"