Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

# Conflicts:
#	tui_gateway/server.py
This commit is contained in:
Brooklyn Nicholson 2026-05-30 13:19:27 -05:00
commit c83cd38391
157 changed files with 10059 additions and 831 deletions

View file

@ -119,17 +119,20 @@ class BrowserUseBrowserProvider(BrowserProvider):
return "Browser Use"
def is_available(self) -> bool:
return self._get_config_or_none() is not None
return self._get_config_or_none(refresh_token=False) is not None
# ------------------------------------------------------------------
# Config resolution (direct API key OR managed Nous gateway)
# ------------------------------------------------------------------
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
def _get_config_or_none(self, *, refresh_token: bool = True) -> Optional[Dict[str, Any]]:
# Import here to avoid a hard dependency at module-import time —
# managed_tool_gateway pulls in the Nous auth stack which can be
# heavy and is not needed for direct-API-key users.
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.managed_tool_gateway import (
peek_nous_access_token,
resolve_managed_tool_gateway,
)
from tools.tool_backend_helpers import prefers_gateway
# Direct API key wins unless the user has explicitly opted into the
@ -142,7 +145,11 @@ class BrowserUseBrowserProvider(BrowserProvider):
"managed_mode": False,
}
managed = resolve_managed_tool_gateway("browser-use")
# Keep availability scans off the synchronous OAuth refresh path.
managed = resolve_managed_tool_gateway(
"browser-use",
token_reader=None if refresh_token else peek_nous_access_token,
)
if managed is None:
return None

View file

@ -2741,6 +2741,8 @@
// Ready/Block/Complete buttons feel like no-ops. See #26744.
const [patchErr, setPatchErr] = useState(null);
const [newComment, setNewComment] = useState("");
const [uploadBusy, setUploadBusy] = useState(false);
const [uploadErr, setUploadErr] = useState(null);
const [editing, setEditing] = useState(false);
// Home-channel notification toggles. homeChannels is the list of platforms
// the user has a /sethome on; each entry has a `subscribed` bool telling
@ -2789,6 +2791,49 @@
}).catch(function (e) { setErr(String(e.message || e)); });
};
// File upload uses raw fetch (not SDK.fetchJSON, which JSON-encodes)
// so the browser sets the multipart boundary. Auth rides the session
// cookie + bearer token, matching the rest of the dashboard.
const handleUpload = function (fileList) {
const files = Array.prototype.slice.call(fileList || []);
if (!files.length) return;
setUploadBusy(true);
setUploadErr(null);
const token = window.__HERMES_SESSION_TOKEN__ || "";
const headers = token ? { Authorization: "Bearer " + token } : {};
const url = withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/attachments`, boardSlug);
// Upload sequentially so a partial failure leaves a clear state.
let chain = Promise.resolve();
files.forEach(function (f) {
chain = chain.then(function () {
const fd = new FormData();
fd.append("file", f, f.name);
return fetch(url, { method: "POST", headers: headers, credentials: "same-origin", body: fd })
.then(function (resp) {
if (!resp.ok) {
return resp.text().then(function (txt) {
throw new Error(parseApiErrorMessage(new Error(resp.status + ": " + txt)));
});
}
});
});
});
chain.then(function () {
load();
props.onRefresh();
}).catch(function (e) {
setUploadErr(String(e.message || e));
}).finally(function () {
setUploadBusy(false);
});
};
const handleDeleteAttachment = function (attachmentId) {
return SDK.fetchJSON(withBoard(`${API}/attachments/${attachmentId}`, boardSlug), { method: "DELETE" })
.then(function () { load(); props.onRefresh(); })
.catch(function (e) { setUploadErr(String(e.message || e)); });
};
const doPatch = function (patch, opts) {
if (opts && opts.confirm && !window.confirm(opts.confirm)) {
return Promise.resolve();
@ -2946,6 +2991,10 @@
homeBusy: homeBusy,
onToggleHomeSub: toggleHomeSubscription,
onRefresh: props.onRefresh,
onUpload: handleUpload,
onDeleteAttachment: handleDeleteAttachment,
uploadBusy: uploadBusy,
uploadErr: uploadErr,
}) : null,
data ? h("div", { className: "hermes-kanban-drawer-comment-row" },
h(Input, {
@ -2968,11 +3017,118 @@
);
}
function _fmtBytes(n) {
n = Number(n) || 0;
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
return (n / (1024 * 1024)).toFixed(1) + " MB";
}
// Attachments section in the task drawer (#35338). Upload button +
// list with download links and a delete (×) per row. The download
// link hits GET /attachments/:id which streams the file; the worker
// context surfaces the same files' absolute paths so a kanban worker
// can read them with the file/terminal tools.
function AttachmentsSection(props) {
const i18n = props.i18n;
const atts = props.attachments || [];
const fileRef = useRef(null);
const [dlErr, setDlErr] = useState(null);
// Download via authenticated fetch → blob → synthetic anchor click.
// A plain <a href> can't carry the session header/bearer the dashboard
// auth middleware requires in loopback mode, so fetch with the token
// and hand the browser a blob URL instead.
function downloadAttachment(a) {
const token = window.__HERMES_SESSION_TOKEN__ || "";
const headers = token ? { Authorization: "Bearer " + token } : {};
const url = withBoard(`${API}/attachments/${a.id}`, props.boardSlug);
setDlErr(null);
fetch(url, { headers: headers, credentials: "same-origin" })
.then(function (resp) {
if (!resp.ok) {
return resp.text().then(function (txt) {
throw new Error(parseApiErrorMessage(new Error(resp.status + ": " + txt)));
});
}
return resp.blob();
})
.then(function (blob) {
const objUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objUrl;
link.download = a.filename || "attachment";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(function () { URL.revokeObjectURL(objUrl); }, 10000);
})
.catch(function (e) { setDlErr(String(e.message || e)); });
}
return h("div", { className: "hermes-kanban-section" },
h("div", { className: "hermes-kanban-section-head" },
`${tx(i18n, "attachments", "Attachments")} (${atts.length})`),
h("input", {
ref: fileRef,
type: "file",
multiple: true,
style: { display: "none" },
onChange: function (e) {
if (props.onUpload) props.onUpload(e.target.files);
// Reset so selecting the same file again re-triggers onChange.
try { e.target.value = ""; } catch (_e) { /* ignore */ }
},
}),
h("div", { className: "flex items-center gap-2 mb-2" },
h(Button, {
size: "sm",
variant: "outline",
disabled: !!props.uploadBusy,
onClick: function () { if (fileRef.current) fileRef.current.click(); },
}, props.uploadBusy
? tx(i18n, "uploading", "Uploading…")
: tx(i18n, "uploadFile", "Upload file")),
),
(props.uploadErr || dlErr)
? h("div", { className: "text-xs text-destructive mb-2" }, props.uploadErr || dlErr)
: null,
atts.length === 0
? h("div", { className: "text-xs text-muted-foreground" },
tx(i18n, "noAttachments", "— no attachments —"))
: atts.map(function (a) {
return h("div", {
key: a.id,
className: "flex items-center justify-between gap-2 py-1 text-sm",
},
h("button", {
type: "button",
className: "hermes-kanban-attachment-link truncate",
title: a.filename,
onClick: function () { downloadAttachment(a); },
}, a.filename),
h("span", { className: "text-xs text-muted-foreground whitespace-nowrap" },
_fmtBytes(a.size)),
h("button", {
type: "button",
className: "hermes-kanban-drawer-close",
title: tx(i18n, "removeAttachment", "Remove attachment"),
onClick: function () {
if (window.confirm(tx(i18n, "confirmRemoveAttachment",
"Remove this attachment?"))) {
if (props.onDelete) props.onDelete(a.id);
}
},
}, "×"),
);
}),
);
}
function TaskDetail(props) {
const { t: i18n } = useI18n();
const t = props.data.task;
const comments = props.data.comments || [];
const events = props.data.events || [];
const attachments = props.data.attachments || [];
const links = props.data.links || { parents: [], children: [] };
return h("div", { className: "hermes-kanban-drawer-body" },
@ -3042,6 +3198,15 @@
h("div", { className: "hermes-kanban-section-head" }, tx(i18n, "result", "Result")),
h(MarkdownBlock, { source: t.result, enabled: props.renderMarkdown }),
) : null,
h(AttachmentsSection, {
attachments: attachments,
boardSlug: props.boardSlug,
onUpload: props.onUpload,
onDelete: props.onDeleteAttachment,
uploadBusy: props.uploadBusy,
uploadErr: props.uploadErr,
i18n: i18n,
}),
h("div", { className: "hermes-kanban-section" },
h("div", { className: "hermes-kanban-section-head" },
`${tx(i18n, "comments", "Comments")} (${comments.length})`),

View file

@ -334,6 +334,11 @@
.hermes-kanban-drawer {
width: min(var(--hermes-kanban-drawer-width, 640px), 92vw);
height: 100vh;
/* Dynamic viewport unit excludes the mobile browser's collapsing chrome
(URL/nav bars) so the drawer's bottom row stays reachable. Falls back to
100vh on browsers without dvh support. */
height: 100dvh;
max-height: 100dvh;
background: var(--color-card);
border-left: 1px solid var(--color-border);
display: flex;
@ -352,10 +357,23 @@
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.8rem;
/* Honor the top safe-area inset (notch) so the task id / close button are
not clipped on mobile. */
padding-top: max(0.6rem, env(safe-area-inset-top));
border-bottom: 1px solid var(--color-border);
font-family: var(--font-mono, ui-monospace, monospace);
}
/* On mobile the dashboard shell renders a fixed top bar (min-h-14, hidden at
the lg breakpoint). The drawer is a body-level z-60 overlay starting at the
viewport top, so its header would sit behind that bar. Push the header down
by the bar height (3.5rem) plus the top safe-area inset. */
@media (max-width: 1023px) {
.hermes-kanban-drawer-head {
padding-top: calc(3.5rem + env(safe-area-inset-top));
}
}
.hermes-kanban-drawer-close {
appearance: none;
background: transparent;
@ -368,10 +386,33 @@
}
.hermes-kanban-drawer-close:hover { color: var(--color-foreground); }
/* Attachment download trigger styled as a link, rendered as a <button>
so the click handler can fetch with the session token (#35338). */
.hermes-kanban-attachment-link {
appearance: none;
background: transparent;
border: 0;
padding: 0;
margin: 0;
text-align: left;
color: var(--color-primary, #6ea8fe);
cursor: pointer;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.hermes-kanban-attachment-link:hover { text-decoration: underline; }
.hermes-kanban-drawer-body {
flex: 1;
overflow-y: auto;
padding: 0.9rem;
/* When no comment row is rendered (loading / error states), the scrolling
body is the bottom-most element extend its bottom padding past the
mobile browser chrome so the last content stays readable. */
padding-bottom: max(0.9rem, calc(0.9rem + env(safe-area-inset-bottom)));
display: flex;
flex-direction: column;
gap: 0.85rem;
@ -530,6 +571,9 @@
display: flex;
gap: 0.4rem;
padding: 0.55rem 0.75rem;
/* Keep the comment input clear of the mobile browser nav bar / home
indicator by extending the bottom padding with the safe-area inset. */
padding-bottom: max(0.55rem, calc(0.55rem + env(safe-area-inset-bottom)));
border-top: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-card) 90%, transparent);
}

View file

@ -43,9 +43,11 @@ import os
import sqlite3
import time
from dataclasses import asdict
from pathlib import Path
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, status as http_status
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect, status as http_status
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from hermes_cli import kanban_db
@ -186,6 +188,21 @@ def _comment_dict(c: kanban_db.Comment) -> dict[str, Any]:
}
def _attachment_dict(a: kanban_db.Attachment) -> dict[str, Any]:
"""Serialise an Attachment for the drawer. ``stored_path`` is the
absolute on-disk path workers read; the UI uses ``id`` for download."""
return {
"id": a.id,
"task_id": a.task_id,
"filename": a.filename,
"content_type": a.content_type,
"size": a.size,
"uploaded_by": a.uploaded_by,
"stored_path": a.stored_path,
"created_at": a.created_at,
}
def _run_dict(r: kanban_db.Run) -> dict[str, Any]:
"""Serialise a Run for the drawer's Run history section."""
return {
@ -531,6 +548,7 @@ def get_task(
"task": task_d,
"comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)],
"events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)],
"attachments": [_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)],
"links": _links_for(conn, task_id),
"runs": [
_run_dict(r)
@ -609,6 +627,165 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
conn.close()
# ---------------------------------------------------------------------------
# Attachments — upload / list / download / delete (#35338)
# ---------------------------------------------------------------------------
# Cap a single upload so a runaway request can't fill the disk. 25 MB
# comfortably covers PDFs, images, and source docs — the kanban use case.
_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
def _safe_attachment_name(raw: str) -> str:
"""Reduce a client-supplied filename to a safe basename.
Strips any directory components (``os.path.basename`` on both
separators) so a malicious ``../../etc/passwd`` or ``C:\\x`` collapses
to its leaf. Rejects empty / dotfile-only names. The result is only
ever joined under the per-task attachments dir, never used verbatim
as a path from the client.
"""
name = (raw or "").replace("\\", "/").split("/")[-1].strip()
# Drop control chars and leading dots so we never write a dotfile or
# a name with embedded NULs/newlines.
name = "".join(ch for ch in name if ch.isprintable() and ch not in '\x00').strip()
name = name.lstrip(".").strip()
if not name:
raise HTTPException(status_code=400, detail="invalid attachment filename")
return name[:200]
@router.get("/tasks/{task_id}/attachments")
def list_task_attachments(task_id: str, board: Optional[str] = Query(None)):
board = _resolve_board(board)
conn = _conn(board=board)
try:
if kanban_db.get_task(conn, task_id) is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
return {
"attachments": [
_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)
]
}
finally:
conn.close()
@router.post("/tasks/{task_id}/attachments")
async def upload_task_attachment(
task_id: str,
file: UploadFile = File(...),
board: Optional[str] = Query(None),
uploaded_by: Optional[str] = Form(None),
):
"""Store an uploaded file for a task and record its metadata.
The blob lands under ``attachments_root(board)/<task_id>/`` with a
sanitised, collision-resolved name. The worker reads it via the
absolute path surfaced in ``build_worker_context``.
"""
board = _resolve_board(board)
conn = _conn(board=board)
try:
if kanban_db.get_task(conn, task_id) is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
safe_name = _safe_attachment_name(file.filename or "")
# Stream to disk with a hard size cap so a huge upload can't fill
# the disk. Read in chunks; abort + clean up if the cap is hit.
dest_dir = kanban_db.task_attachments_dir(task_id, board=board)
dest_dir.mkdir(parents=True, exist_ok=True)
# Resolve name collisions: foo.pdf → foo (1).pdf, foo (2).pdf, …
stem, dot, ext = safe_name.partition(".")
candidate = safe_name
n = 1
while (dest_dir / candidate).exists():
candidate = f"{stem} ({n}){dot}{ext}"
n += 1
dest_path = dest_dir / candidate
total = 0
try:
with open(dest_path, "wb") as out:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > _MAX_ATTACHMENT_BYTES:
out.close()
dest_path.unlink(missing_ok=True)
raise HTTPException(
status_code=413,
detail=(
f"attachment exceeds {_MAX_ATTACHMENT_BYTES // (1024 * 1024)} MB limit"
),
)
out.write(chunk)
except HTTPException:
raise
except OSError as exc:
raise HTTPException(status_code=500, detail=f"failed to store attachment: {exc}")
att_id = kanban_db.add_attachment(
conn,
task_id,
filename=candidate,
stored_path=str(dest_path.resolve()),
content_type=file.content_type,
size=total,
uploaded_by=(uploaded_by or "dashboard"),
)
att = kanban_db.get_attachment(conn, att_id)
return {"attachment": _attachment_dict(att) if att else None}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
finally:
conn.close()
@router.get("/attachments/{attachment_id}")
def download_attachment(attachment_id: int, board: Optional[str] = Query(None)):
board = _resolve_board(board)
conn = _conn(board=board)
try:
att = kanban_db.get_attachment(conn, attachment_id)
if att is None:
raise HTTPException(status_code=404, detail="attachment not found")
# Confirm the blob still lives under the board's attachments root
# before serving — defense in depth against a tampered DB row.
root = kanban_db.attachments_root(board=board).resolve()
try:
stored = Path(att.stored_path).resolve()
stored.relative_to(root)
except (ValueError, OSError):
raise HTTPException(status_code=404, detail="attachment file unavailable")
if not stored.is_file():
raise HTTPException(status_code=404, detail="attachment file missing on disk")
return FileResponse(
path=str(stored),
filename=att.filename,
media_type=att.content_type or "application/octet-stream",
)
finally:
conn.close()
@router.delete("/attachments/{attachment_id}")
def remove_attachment(attachment_id: int, board: Optional[str] = Query(None)):
board = _resolve_board(board)
conn = _conn(board=board)
try:
att = kanban_db.delete_attachment(conn, attachment_id)
if att is None:
raise HTTPException(status_code=404, detail="attachment not found")
return {"ok": True, "id": attachment_id}
finally:
conn.close()
# ---------------------------------------------------------------------------
# PATCH /tasks/:id (status / assignee / priority / title / body)
# ---------------------------------------------------------------------------

View file

@ -633,7 +633,8 @@ class HindsightMemoryProvider(MemoryProvider):
except Exception:
pass
existing.update(values)
config_path.write_text(json.dumps(existing, indent=2))
from utils import atomic_json_write
atomic_json_write(config_path, existing, mode=0o600)
def post_setup(self, hermes_home: str, config: dict) -> None:
"""Custom setup wizard — installs only the deps needed for the selected mode."""

View file

@ -12,8 +12,8 @@ AI-native cross-session user modeling with multi-pass dialectic reasoning, sessi
## Setup
```bash
hermes honcho setup # full interactive wizard (cloud or local)
hermes memory setup # generic picker, also works
hermes memory setup honcho # configure Honcho directly (works on a fresh install)
hermes memory setup # generic picker, choose Honcho from the list
```
Or manually:
@ -22,6 +22,10 @@ hermes config set memory.provider honcho
echo "HONCHO_API_KEY=***" >> ~/.hermes/.env
```
> `hermes honcho setup` also works, but only **after** Honcho is the active
> memory provider — the `honcho` subcommand is registered for the active
> provider only. On a fresh install, use `hermes memory setup honcho`.
## Architecture Overview
### Two-Layer Context Injection
@ -109,7 +113,7 @@ Config is read from the first file that exists:
| 2 | `~/.hermes/honcho.json` | Default profile (shared host blocks) |
| 3 | `~/.honcho/config.json` | Global (cross-app interop) |
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.<profile>`.
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_<profile>`.
For every key, resolution order is: **host block > root > env var > default**.
@ -154,7 +158,7 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a
**Host vs root semantics.** All three keys are accepted at both root and `hosts.<host>` levels. Host-level wins. For maps and prefixes, host-level *replaces* the root value as a whole (not merge), so a host can intentionally own its identity universe or wipe it with `userPeerAliases: {}` / `runtimePeerPrefix: ""`.
**Deployment shapes** (`hermes honcho setup` asks one prompt to set these):
**Deployment shapes** (`hermes memory setup honcho` asks one prompt to set these):
- **Single-operator**`pinUserPeer: true`. All gateway users → `peerName`. Recommended for personal use where you connect Hermes to your own Telegram/Discord/etc.
- **Multi-user gateway**`pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. Recommended for bots serving many humans.
@ -225,7 +229,7 @@ Multiple Hermes profiles can share one workspace while maintaining separate AI i
"recallMode": "hybrid",
"sessionStrategy": "per-directory"
},
"hermes.coder": {
"hermes_coder": {
"aiPeer": "coder",
"recallMode": "tools",
"sessionStrategy": "per-repo"
@ -236,7 +240,7 @@ Multiple Hermes profiles can share one workspace while maintaining separate AI i
Both profiles see the same user (`yourname`) in the same shared environment (`hermes`), but each AI peer builds its own observations, conclusions, and behavior patterns. The coder's memory stays code-oriented; the main agent's stays broad.
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.<profile>` (e.g. `hermes -p coder` → host key `hermes.coder`).
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_<profile>` (e.g. `hermes -p coder` -> host key `hermes_coder`). Older `hermes.<profile>` host blocks are still read for compatibility and are migrated when the CLI writes profile-scoped Honcho config.
### Dialectic & Reasoning
@ -307,7 +311,8 @@ Presets:
| Command | Description |
|---------|-------------|
| `hermes honcho setup` | Full interactive setup wizard |
| `hermes memory setup honcho` | Configure Honcho directly — works on a fresh install |
| `hermes honcho setup` | Interactive setup wizard (only registered once Honcho is the active provider; redirects to `hermes memory setup`) |
| `hermes honcho status` | Show resolved config for active profile |
| `hermes honcho enable` / `disable` | Toggle Honcho for active profile |
| `hermes honcho mode <mode>` | Change recall or observation mode |
@ -344,7 +349,7 @@ Presets:
"dialecticMaxChars": 600,
"saveMessages": true
},
"hermes.coder": {
"hermes_coder": {
"enabled": true,
"aiPeer": "coder",
"sessionStrategy": "per-repo",

View file

@ -249,6 +249,7 @@ class HonchoMemoryProvider(MemoryProvider):
def save_config(self, values, hermes_home):
"""Write config to $HERMES_HOME/honcho.json (Honcho SDK native format)."""
import json
import os
from pathlib import Path
config_path = Path(hermes_home) / "honcho.json"
existing = {}
@ -258,7 +259,8 @@ class HonchoMemoryProvider(MemoryProvider):
except Exception:
pass
existing.update(values)
config_path.write_text(json.dumps(existing, indent=2))
from utils import atomic_json_write
atomic_json_write(config_path, existing, mode=0o600)
def get_config_schema(self):
return [

View file

@ -11,7 +11,7 @@ import sys
from pathlib import Path
from hermes_constants import get_hermes_home
from plugins.memory.honcho.client import resolve_active_host, resolve_config_path, HOST
from plugins.memory.honcho.client import _host_block, profile_host_key, resolve_active_host, resolve_config_path, HOST
from hermes_cli.config import cfg_get
@ -36,7 +36,7 @@ def clone_honcho_for_profile(profile_name: str) -> bool:
if not default_block and not has_key:
return False
new_host = f"{HOST}.{profile_name}"
new_host = profile_host_key(profile_name)
if new_host in hosts:
return False # already exists
@ -192,7 +192,7 @@ def cmd_sync(args) -> None:
if p.name == "default":
continue
if clone_honcho_for_profile(p.name):
print(f" + {p.name} -> hermes.{p.name}")
print(f" + {p.name} -> {profile_host_key(p.name)}")
created += 1
else:
skipped += 1
@ -243,7 +243,7 @@ def _host_key() -> str:
if _profile_override:
if _profile_override in {"default", "custom"}:
return HOST
return f"{HOST}.{_profile_override}"
return profile_host_key(_profile_override)
return resolve_active_host()
@ -275,10 +275,8 @@ def _read_config() -> dict:
def _write_config(cfg: dict, path: Path | None = None) -> None:
path = path or _local_config_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(cfg, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
from utils import atomic_json_write
atomic_json_write(path, cfg, mode=0o600)
def _resolve_api_key(cfg: dict) -> str:
@ -292,7 +290,7 @@ def _resolve_api_key(cfg: dict) -> str:
config shapes, e.g. ``localhost:8000``) still pass the Honcho SDK
will reject them itself with a clearer error than ours.
"""
host_key = ((cfg.get("hosts") or {}).get(_host_key()) or {}).get("apiKey")
host_key = _host_block(cfg, _host_key()).get("apiKey")
key = host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
if not key:
base_url = cfg.get("baseUrl") or cfg.get("base_url") or os.environ.get("HONCHO_BASE_URL", "")
@ -462,21 +460,58 @@ def cmd_setup(args) -> None:
cfg.pop("base_url", None)
if is_local:
# --- Local: ask for base URL, skip or clear API key ---
# --- Local: ask for base URL, optionally accept a JWT for auth ---
current_url = cfg.get("baseUrl") or ""
new_url = _prompt("Base URL", default=current_url or "http://localhost:8000")
if new_url:
cfg["baseUrl"] = new_url
# For local no-auth, the SDK must not send an API key.
# We keep the key in config (for cloud switching later) but
# the client should skip auth when baseUrl is local.
current_key = cfg.get("apiKey", "")
if current_key:
print(f"\n API key present in config (kept for cloud/hybrid use).")
print(" Local connections will skip auth automatically.")
# Self-hosted Honcho can run with AUTH_USE_AUTH=true and an
# AUTH_JWT_SECRET on the server side. In that case clients must
# send a JWT signed with that secret as the bearer token (the
# Honcho SDK takes it via ``api_key=``). Cloud users got prompted
# for a key already; the local path historically skipped this and
# forced users to disable auth on the server. Offer the prompt
# here too. We store it under the host block (not the top-level
# apiKey) so ``get_honcho_client`` recognises it as an explicit
# local auth opt-in (see ``_host_has_key`` in client.py) and
# cloud/hybrid switching is unaffected.
current_host_key = hermes_host.get("apiKey", "")
masked = (
f"...{current_host_key[-8:]}"
if len(current_host_key) > 8
else ("set" if current_host_key else "not set")
)
print(
"\n Local Honcho auth (JWT signed with the server's "
"AUTH_JWT_SECRET)."
)
print(
" Leave blank if your server runs with AUTH_USE_AUTH=false. "
f"Current: {masked}"
)
new_local_key = _prompt(
"Local JWT / bearer token (blank to skip / keep current)",
secret=True,
)
if new_local_key:
hermes_host["apiKey"] = new_local_key
elif current_host_key:
print(" Keeping existing local JWT.")
else:
print("\n No API key set. Local no-auth ready.")
# Surface the top-level key situation for transparency.
top_key = cfg.get("apiKey", "")
if top_key:
print(
"\n Top-level API key present in config (kept for "
"cloud/hybrid use)."
)
print(
" Local connections will skip auth automatically "
"until a local JWT is set above."
)
else:
print("\n No local JWT set. Local no-auth ready.")
else:
# --- Cloud: set default base URL, require API key ---
cfg.pop("baseUrl", None) # cloud uses SDK default

View file

@ -32,6 +32,24 @@ logger = logging.getLogger(__name__)
HOST = "hermes"
def profile_host_key(profile: str | None) -> str:
"""Return the safe Honcho host key for a Hermes profile."""
if not profile or profile in {"default", "custom"}:
return HOST
sanitized = "".join(c if c.isalnum() or c in "_-" else "_" for c in profile).strip("_")
return f"{HOST}_{sanitized or 'profile'}"
def _host_block(raw: dict, host: str) -> dict:
"""Return host config, accepting legacy dot-form profile host keys."""
hosts = raw.get("hosts") or {}
block = hosts.get(host, {})
if block or not host.startswith(f"{HOST}_"):
return block
legacy = f"{HOST}.{host[len(HOST) + 1:]}"
return hosts.get(legacy, {})
def resolve_active_host() -> str:
"""Derive the Honcho host key from the active Hermes profile.
@ -47,8 +65,7 @@ def resolve_active_host() -> str:
try:
from hermes_cli.profiles import get_active_profile_name
profile = get_active_profile_name()
if profile and profile not in {"default", "custom"}:
return f"{HOST}.{profile}"
return profile_host_key(profile)
except Exception:
pass
return HOST
@ -406,7 +423,7 @@ class HonchoClientConfig:
logger.warning("Failed to read %s: %s, falling back to env", path, e)
return cls.from_env(host=resolved_host)
host_block = (raw.get("hosts") or {}).get(resolved_host, {})
host_block = _host_block(raw, resolved_host)
# A hosts.hermes block or explicit enabled flag means the user
# intentionally configured Honcho for this host.
_explicitly_configured = bool(host_block) or raw.get("enabled") is True
@ -811,7 +828,10 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
or "::1" in resolved_base_url
)
if _is_local:
# Check if the host block has its own apiKey (explicit local auth)
# Check if the host block has its own apiKey (explicit local auth).
# Auth-skipping is loopback-only: a stored key is likely a cloud key
# that would break a no-auth local server, so we substitute the SDK's
# required-non-empty placeholder unless the host block opts in.
_raw = config.raw or {}
_host_block = (_raw.get("hosts") or {}).get(config.host, {})
_host_has_key = bool(_host_block.get("apiKey"))
@ -819,6 +839,18 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
else:
effective_api_key = config.api_key
# The Honcho SDK's route builders (e.g. routes.workspaces()) already
# include the version prefix (e.g. "/v3/workspaces"). When a user-supplied
# base_url already ends in a version segment (e.g.
# "http://localhost:38000/v3", "https://honcho.my.ts.net/v3"), concatenating
# the two produces "/v3/v3/workspaces" → 404 on every call. This is a pure
# routing concern independent of host, so strip a trailing version segment
# from ANY base_url — loopback, LAN, custom domain, or cloud alike. The
# SDK then appends its own versioned paths correctly.
if resolved_base_url:
import re as _re
resolved_base_url = _re.sub(r"/v\d+/*$", "", resolved_base_url).rstrip("/")
kwargs: dict = {
"workspace_id": config.workspace_id,
"api_key": effective_api_key,

View file

@ -155,7 +155,8 @@ class Mem0MemoryProvider(MemoryProvider):
except Exception:
pass
existing.update(values)
config_path.write_text(json.dumps(existing, indent=2))
from utils import atomic_json_write
atomic_json_write(config_path, existing, mode=0o600)
def get_config_schema(self):
return [

View file

@ -152,7 +152,8 @@ def _save_supermemory_config(values: dict, hermes_home: str) -> None:
except Exception:
existing = {}
existing.update(values)
config_path.write_text(json.dumps(existing, indent=2, sort_keys=True) + "\n", encoding="utf-8")
from utils import atomic_json_write
atomic_json_write(config_path, existing, mode=0o600, sort_keys=True)
def _detect_category(text: str) -> str:

View file

@ -6093,16 +6093,17 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
``gateway/config.py::load_gateway_config()`` before this migration.
The DiscordAdapter reads its runtime configuration via ``os.getenv()``
throughout the connect / handle code paths (``DISCORD_REQUIRE_MENTION``,
``DISCORD_FREE_RESPONSE_CHANNELS``, ``DISCORD_AUTO_THREAD``,
``DISCORD_REACTIONS``, ``DISCORD_IGNORED_CHANNELS``,
``DISCORD_ALLOWED_CHANNELS``, ``DISCORD_NO_THREAD_CHANNELS``,
``DISCORD_HISTORY_BACKFILL``, ``DISCORD_HISTORY_BACKFILL_LIMIT``,
``DISCORD_ALLOW_MENTION_*``, ``DISCORD_REPLY_TO_MODE``,
``DISCORD_THREAD_REQUIRE_MENTION``). Rather than rewrite ~50 call sites
inside the adapter to read from ``PlatformConfig.extra`` instead, this
hook keeps the existing env-driven model and merely owns the
YAMLenv translation here, next to the adapter that consumes it.
throughout the connect / handle code paths (``DISCORD_ALLOWED_USERS``,
``DISCORD_REQUIRE_MENTION``, ``DISCORD_FREE_RESPONSE_CHANNELS``,
``DISCORD_AUTO_THREAD``, ``DISCORD_REACTIONS``,
``DISCORD_IGNORED_CHANNELS``, ``DISCORD_ALLOWED_CHANNELS``,
``DISCORD_NO_THREAD_CHANNELS``, ``DISCORD_HISTORY_BACKFILL``,
``DISCORD_HISTORY_BACKFILL_LIMIT``, ``DISCORD_ALLOW_MENTION_*``,
``DISCORD_REPLY_TO_MODE``, ``DISCORD_THREAD_REQUIRE_MENTION``).
Rather than rewrite ~50 call sites inside the adapter to read from
``PlatformConfig.extra`` instead, this hook keeps the existing
env-driven model and merely owns the YAMLenv translation here, next to
the adapter that consumes it.
Env vars take precedence over YAML every assignment is guarded by
``not os.getenv(...)`` so explicit env vars survive a config.yaml
@ -6113,6 +6114,22 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"):
os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower()
platforms_cfg = yaml_cfg.get("platforms")
platform_extra_cfg = {}
if isinstance(platforms_cfg, dict):
discord_platform_cfg = platforms_cfg.get("discord")
if isinstance(discord_platform_cfg, dict):
candidate_extra = discord_platform_cfg.get("extra")
if isinstance(candidate_extra, dict):
platform_extra_cfg = candidate_extra
allowed_users_cfg = (
discord_cfg["allow_from"] if "allow_from" in discord_cfg
else platform_extra_cfg.get("allow_from")
)
if allowed_users_cfg is not None and not os.getenv("DISCORD_ALLOWED_USERS"):
if isinstance(allowed_users_cfg, list):
allowed_users_cfg = ",".join(str(v) for v in allowed_users_cfg)
os.environ["DISCORD_ALLOWED_USERS"] = str(allowed_users_cfg)
frc = discord_cfg.get("free_response_channels")
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
if isinstance(frc, list):

View file

@ -146,16 +146,16 @@ def _get_firecrawl_gateway_url() -> str:
def _is_tool_gateway_ready() -> bool:
"""Return True when gateway URL + Nous Subscriber token are available.
Reads ``read_nous_access_token`` and ``resolve_managed_tool_gateway``
Reads ``peek_nous_access_token`` and ``resolve_managed_tool_gateway``
via :mod:`tools.web_tools` rather than direct imports, so unit tests
that ``patch("tools.web_tools._read_nous_access_token", ...)`` see
that ``patch("tools.web_tools._peek_nous_access_token", ...)`` see
their patches honored. The names are re-exported on
:mod:`tools.web_tools` for exactly this reason.
"""
import tools.web_tools as _wt
return _wt.resolve_managed_tool_gateway(
"firecrawl", token_reader=_wt._read_nous_access_token
"firecrawl", token_reader=_wt._peek_nous_access_token
) is not None