Merge remote-tracking branch 'origin/main' into hermes/hermes-6fe26723

This commit is contained in:
Ben 2026-06-18 16:18:33 +10:00
commit e1e53bff9d
21 changed files with 3503 additions and 114 deletions

View file

@ -1156,6 +1156,9 @@ def init_agent(
"hermes_home": str(get_hermes_home()),
"agent_context": "primary",
}
if _init_kwargs["platform"] == "cli":
_init_kwargs["warning_callback"] = agent._emit_warning
_init_kwargs["status_callback"] = agent._emit_status
# Thread session title for memory provider scoping
# (e.g. honcho uses this to derive chat-scoped session keys)
if agent._session_db:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

View file

@ -15,24 +15,50 @@ from pathlib import Path
from hermes_constants import get_hermes_home
from hermes_cli.secret_prompt import masked_secret_prompt
_CANCELLED = -1
# ---------------------------------------------------------------------------
# Curses-based interactive picker (same pattern as hermes tools)
# ---------------------------------------------------------------------------
def _curses_select(title: str, items: list[tuple[str, str]], default: int = 0) -> int:
def _curses_select(
title: str,
items: list[tuple[str, str]],
default: int = 0,
*,
cancel_returns: int | None = None,
) -> int:
"""Interactive single-select with arrow keys.
items: list of (label, description) tuples.
Returns selected index, or default on escape/quit.
Returns selected index, or cancel_returns/default on escape/quit.
"""
from hermes_cli.curses_ui import curses_radiolist
if cancel_returns is None:
cancel_returns = default
# Format (label, desc) tuples into display strings
display_items = [
f"{label} {desc}" if desc else label
f"{label} - {desc}" if desc else label
for label, desc in items
]
return curses_radiolist(title, display_items, selected=default, cancel_returns=default)
result = curses_radiolist(title, display_items, selected=default, cancel_returns=cancel_returns)
_clear_interactive_transition()
return result
def _print_cancelled_setup() -> None:
print("\n Cancelled. No changes saved.\n")
def _clear_interactive_transition() -> None:
"""Clear stale curses content before entering a follow-up setup screen."""
if not sys.stdout.isatty():
return
sys.stdout.write("\033[2J\033[H")
sys.stdout.flush()
def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
@ -205,6 +231,8 @@ def cmd_setup_provider(provider_name: str) -> None:
name, _, provider = match
_clear_interactive_transition()
_install_dependencies(name)
config = load_config()
@ -241,14 +269,17 @@ def cmd_setup(args) -> None:
items.append(("Built-in only", "— MEMORY.md / USER.md (default)"))
builtin_idx = len(items) - 1
selected = _curses_select("Memory provider setup", items, default=builtin_idx)
selected = _curses_select("Memory provider setup", items, default=builtin_idx, cancel_returns=_CANCELLED)
if selected == _CANCELLED:
_print_cancelled_setup()
return
config = load_config()
if not isinstance(config.get("memory"), dict):
config["memory"] = {}
# Built-in only
if selected >= len(providers) or selected < 0:
if selected >= len(providers):
config["memory"]["provider"] = ""
save_config(config)
print("\n ✓ Memory provider: built-in only")
@ -257,6 +288,8 @@ def cmd_setup(args) -> None:
name, _, provider = providers[selected]
_clear_interactive_transition()
# Install pip dependencies if declared in plugin.yaml
_install_dependencies(name)
@ -309,7 +342,10 @@ def cmd_setup(args) -> None:
current_idx = 0
if current and current in choices:
current_idx = choices.index(current)
sel = _curses_select(f" {desc}", choice_items, default=current_idx)
sel = _curses_select(f" {desc}", choice_items, default=current_idx, cancel_returns=_CANCELLED)
if sel == _CANCELLED:
_print_cancelled_setup()
return
provider_config[key] = choices[sel]
elif is_secret:
# Prompt for secret
@ -407,43 +443,53 @@ def cmd_status(args) -> None:
print(f" Built-in: always active")
print(f" Provider: {provider_name or '(none — built-in only)'}")
providers = _get_available_providers()
provider = None
for pname, _, candidate in providers:
if pname == provider_name:
provider = candidate
break
if provider_name:
provider_config = mem_config.get(provider_name, {})
if provider_config:
display_config = provider_config
if provider and hasattr(provider, "get_status_config"):
try:
display_config = provider.get_status_config(provider_config)
except Exception as e:
display_config = dict(provider_config) if isinstance(provider_config, dict) else provider_config
if isinstance(display_config, dict):
display_config["status_config_error"] = str(e)
if display_config:
print(f"\n {provider_name} config:")
for key, val in provider_config.items():
for key, val in display_config.items():
print(f" {key}: {val}")
providers = _get_available_providers()
found = any(name == provider_name for name, _, _ in providers)
if found:
if provider:
print(f"\n Plugin: installed ✓")
for pname, _, p in providers:
if pname == provider_name:
if p.is_available():
print(f" Status: available ✓")
else:
print(f" Status: not available ✗")
schema = p.get_config_schema() if hasattr(p, "get_config_schema") else []
# Check all fields that have env_var (both secret and non-secret)
required_fields = [f for f in schema if f.get("env_var")]
if required_fields:
print(f" Missing:")
for f in required_fields:
env_var = f.get("env_var", "")
url = f.get("url", "")
is_set = bool(os.environ.get(env_var))
mark = "" if is_set else ""
line = f" {mark} {env_var}"
if url and not is_set:
line += f"{url}"
print(line)
break
if provider.is_available():
print(f" Status: available ✓")
else:
print(f" Status: not available ✗")
schema = provider.get_config_schema() if hasattr(provider, "get_config_schema") else []
# Check all fields that have env_var (both secret and non-secret)
required_fields = [f for f in schema if f.get("env_var")]
if required_fields:
print(f" Missing:")
for f in required_fields:
env_var = f.get("env_var", "")
url = f.get("url", "")
is_set = bool(os.environ.get(env_var))
mark = "" if is_set else ""
line = f" {mark} {env_var}"
if url and not is_set:
line += f"{url}"
print(line)
else:
print(f"\n Plugin: NOT installed ✗")
print(f" Install the '{provider_name}' memory plugin to ~/.hermes/plugins/")
providers = _get_available_providers()
if providers:
print(f"\n Installed plugins:")
for pname, desc, _ in providers:

View file

@ -27,16 +27,16 @@ def _collect_masked_input(
while True:
ch = read_char()
if ch == "":
write("\n")
write("\r\n")
raise EOFError
if ch in _ENTER_CHARS:
write("\n")
write("\r\n")
return "".join(value)
if ch == "\x03":
write("\n")
write("\r\n")
raise KeyboardInterrupt
if ch in _EOF_CHARS:
write("\n")
write("\r\n")
raise EOFError
if ch in _BACKSPACE_CHARS:
if value:

View file

@ -70,7 +70,10 @@ from gateway.status import (
from utils import env_var_enabled
try:
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi import (
FastAPI, File, Form, HTTPException, Request, UploadFile,
WebSocket, WebSocketDisconnect,
)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
@ -82,7 +85,10 @@ except ImportError:
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("tool.dashboard", prompt=False)
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi import (
FastAPI, File, Form, HTTPException, Request, UploadFile,
WebSocket, WebSocketDisconnect,
)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
@ -1483,6 +1489,74 @@ async def upload_managed_file(payload: ManagedFileUpload, request: Request):
}
# Stream uploads to disk in fixed-size chunks. The legacy JSON endpoint above
# buffers the whole file as a base64 data URL in a JSON body, which (a) inflates
# the payload ~33%, (b) holds the entire file (plus its decoded copy) in memory,
# and (c) reliably trips upstream proxy body-size/timeout limits with a 502 on
# large backup archives (NS-501). This multipart endpoint reads the request body
# in 1 MiB chunks straight to a temp file, enforces the size cap as it goes, and
# atomically renames into place — constant memory, no base64 inflation.
_UPLOAD_CHUNK_BYTES = 1024 * 1024
@app.post("/api/files/upload-stream")
async def upload_managed_file_stream(
request: Request,
file: UploadFile = File(...),
path: str = Form(...),
overwrite: bool = Form(True),
):
policy, target, display_path = _resolve_managed_path(path, request, for_write=True)
if target.exists() and target.is_dir():
raise HTTPException(status_code=409, detail="A directory already exists at that path")
if target.exists() and not overwrite:
raise HTTPException(status_code=409, detail="File already exists")
try:
target.parent.mkdir(parents=True, exist_ok=True)
except PermissionError:
raise HTTPException(status_code=403, detail="File is not writable")
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not create parent directory: {exc}")
# Write to a sibling temp file first so a partial/aborted upload never
# clobbers an existing file, then atomically rename into place.
tmp_fd, tmp_name = tempfile.mkstemp(
prefix=f".{target.name}.", suffix=".upload", dir=str(target.parent)
)
tmp_path = Path(tmp_name)
total = 0
try:
with os.fdopen(tmp_fd, "wb") as out:
while True:
chunk = await file.read(_UPLOAD_CHUNK_BYTES)
if not chunk:
break
total += len(chunk)
if total > _MANAGED_FILE_MAX_BYTES:
raise HTTPException(status_code=413, detail="File is too large")
out.write(chunk)
os.replace(tmp_path, target)
except HTTPException:
tmp_path.unlink(missing_ok=True)
raise
except PermissionError:
tmp_path.unlink(missing_ok=True)
raise HTTPException(status_code=403, detail="File is not writable")
except OSError as exc:
tmp_path.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail=f"Could not write file: {exc}")
finally:
await file.close()
return {
"ok": True,
"entry": _managed_file_entry(policy, target),
"path": display_path,
**_managed_response_meta(policy),
}
@app.post("/api/files/mkdir")
async def create_managed_directory(payload: ManagedDirectoryCreate, request: Request):
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

View file

@ -702,7 +702,7 @@ class HindsightMemoryProvider(MemoryProvider):
from hermes_cli.config import save_config
from hermes_cli.secret_prompt import masked_secret_prompt
from hermes_cli.memory_setup import _curses_select
from hermes_cli.memory_setup import _CANCELLED, _curses_select, _print_cancelled_setup
print("\n Configuring Hindsight memory:\n")
@ -719,7 +719,10 @@ class HindsightMemoryProvider(MemoryProvider):
]
existing_mode = existing_config.get("mode")
mode_default_idx = mode_values.index(existing_mode) if existing_mode in mode_values else 0
mode_idx = _curses_select(" Select mode", mode_items, default=mode_default_idx)
mode_idx = _curses_select(" Select mode", mode_items, default=mode_default_idx, cancel_returns=_CANCELLED)
if mode_idx == _CANCELLED:
_print_cancelled_setup()
return
mode = mode_values[mode_idx]
provider_config: dict = dict(existing_config)
@ -737,6 +740,27 @@ class HindsightMemoryProvider(MemoryProvider):
else:
deps_to_install = [cloud_dep]
llm_provider = ""
if mode == "local_embedded":
providers_list = list(_PROVIDER_DEFAULT_MODELS.keys())
llm_items = [
(p, f"default model: {_PROVIDER_DEFAULT_MODELS[p]}")
for p in providers_list
]
existing_llm_provider = provider_config.get("llm_provider")
llm_default_idx = providers_list.index(existing_llm_provider) if existing_llm_provider in providers_list else 0
llm_idx = _curses_select(
" Select LLM provider",
llm_items,
default=llm_default_idx,
cancel_returns=_CANCELLED,
)
if llm_idx == _CANCELLED:
_print_cancelled_setup()
return
llm_provider = providers_list[llm_idx]
provider_config["llm_provider"] = llm_provider
print("\n Checking dependencies...")
uv_path = shutil.which("uv")
if not uv_path:
@ -785,18 +809,6 @@ class HindsightMemoryProvider(MemoryProvider):
env_writes["HINDSIGHT_API_KEY"] = api_key
else: # local_embedded
providers_list = list(_PROVIDER_DEFAULT_MODELS.keys())
llm_items = [
(p, f"default model: {_PROVIDER_DEFAULT_MODELS[p]}")
for p in providers_list
]
existing_llm_provider = provider_config.get("llm_provider")
llm_default_idx = providers_list.index(existing_llm_provider) if existing_llm_provider in providers_list else 0
llm_idx = _curses_select(" Select LLM provider", llm_items, default=llm_default_idx)
llm_provider = providers_list[llm_idx]
provider_config["llm_provider"] = llm_provider
if llm_provider == "openai_compatible":
existing_base_url = provider_config.get("llm_base_url", "")
prompt = " LLM endpoint URL (e.g. http://192.168.1.10:8080/v1)"

View file

@ -14,6 +14,10 @@ Context database by Volcengine (ByteDance) with filesystem-style knowledge hiera
hermes memory setup # select "openviking"
```
The setup can link to an existing `~/.openviking/ovcli.conf`, copy its current
connection values into Hermes, or create a minimal `ovcli.conf` when one does
not exist.
Or manually:
```bash
hermes config set memory.provider openviking
@ -28,6 +32,9 @@ All config via environment variables in `.env`:
|---------|---------|-------------|
| `OPENVIKING_ENDPOINT` | `http://127.0.0.1:1933` | Server URL |
| `OPENVIKING_API_KEY` | (none) | API key (optional) |
| `OPENVIKING_ACCOUNT` | (none) | Tenant account override |
| `OPENVIKING_USER` | (none) | Tenant user override |
| `OPENVIKING_AGENT` | `hermes` | Tenant agent namespace |
## Tools

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,6 @@ version: 2.0.0
description: "OpenViking context database — session-managed memory with automatic extraction, tiered retrieval, and filesystem-style knowledge browsing."
pip_dependencies:
- httpx
requires_env:
- OPENVIKING_ENDPOINT
requires_env: []
hooks:
- on_session_end

View file

@ -106,6 +106,11 @@ dependencies = [
"pathspec==1.1.1",
"fastapi>=0.104.0,<1",
"uvicorn[standard]>=0.24.0,<1",
# Streaming multipart uploads for the dashboard file manager (NS-501).
# FastAPI's UploadFile/Form depend on python-multipart; it is NOT pulled in
# by fastapi itself, so the dashboard's multipart upload endpoint would 500
# without an explicit dependency here (and in the `web` extra below).
"python-multipart>=0.0.9,<1",
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
"pywinpty>=2.0.0,<3; sys_platform == 'win32'",
# Image resize recovery for the vision tools. Pillow shrinks oversized images
@ -253,7 +258,7 @@ youtube = [
# `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean.
# starlette==1.0.1 pinned for CVE-2026-48710 (BadHost) — fastapi pulls Starlette
# transitively and pre-1.0.1 is the vulnerable range. See the mcp extra above.
web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.0.1"]
web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.0.1", "python-multipart==0.0.20"]
all = [
# Policy (2026-05-12): `[all]` includes only extras that genuinely
# CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every

View file

@ -0,0 +1,198 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
import hermes_cli.memory_setup as memory_setup
from hermes_cli.memory_setup import _CANCELLED, _curses_select
def test_curses_select_cancel_defaults_to_selected(monkeypatch):
captured = {}
def fake_radiolist(title, items, selected=0, *, cancel_returns=None):
captured.update({
"title": title,
"items": items,
"selected": selected,
"cancel_returns": cancel_returns,
})
return cancel_returns
monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", fake_radiolist)
result = _curses_select("Pick one", [("first", "desc"), ("second", "")], default=1)
assert result == 1
assert captured == {
"title": "Pick one",
"items": ["first - desc", "second"],
"selected": 1,
"cancel_returns": 1,
}
def test_curses_select_accepts_explicit_cancel_value(monkeypatch):
captured = {}
def fake_radiolist(title, items, selected=0, *, cancel_returns=None):
captured["cancel_returns"] = cancel_returns
return cancel_returns
monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", fake_radiolist)
result = _curses_select("Pick one", [("first", "")], default=0, cancel_returns=_CANCELLED)
assert result == _CANCELLED
assert captured["cancel_returns"] == _CANCELLED
def test_curses_select_clears_after_picker_returns(monkeypatch):
events = []
def fake_radiolist(title, items, selected=0, *, cancel_returns=None):
events.append("picker")
return selected
monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", fake_radiolist)
monkeypatch.setattr(memory_setup, "_clear_interactive_transition", lambda: events.append("clear"))
result = _curses_select("Pick one", [("first", "")], default=0)
assert result == 0
assert events == ["picker", "clear"]
def test_cmd_setup_top_level_cancel_writes_nothing(monkeypatch):
save_config = MagicMock()
load_config = MagicMock(side_effect=AssertionError("cancel should not load config"))
monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("fake", "local", object())])
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: kwargs["cancel_returns"])
monkeypatch.setattr("hermes_cli.config.load_config", load_config)
monkeypatch.setattr("hermes_cli.config.save_config", save_config)
memory_setup.cmd_setup(SimpleNamespace())
load_config.assert_not_called()
save_config.assert_not_called()
def test_cmd_setup_builtin_selection_still_saves_builtin(monkeypatch):
save_config = MagicMock()
config = {"memory": {"provider": "openviking"}}
providers = [("fake", "local", object())]
monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: providers)
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: len(providers))
monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
monkeypatch.setattr("hermes_cli.config.save_config", save_config)
memory_setup.cmd_setup(SimpleNamespace())
assert config["memory"]["provider"] == ""
save_config.assert_called_once_with(config)
def test_cmd_setup_clears_interactive_picker_before_provider_post_setup(monkeypatch):
events = []
class PostSetupProvider:
def post_setup(self, hermes_home, config):
events.append("post_setup")
monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("openviking", "local", PostSetupProvider())])
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: events.append("select") or 0)
monkeypatch.setattr(memory_setup, "_clear_interactive_transition", lambda: events.append("clear"), raising=False)
monkeypatch.setattr(memory_setup, "_install_dependencies", lambda name: events.append("install"))
monkeypatch.setattr(memory_setup, "get_hermes_home", lambda: "/tmp/hermes-test")
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"memory": {}})
memory_setup.cmd_setup(SimpleNamespace())
assert events == ["select", "clear", "install", "post_setup"]
def test_cmd_setup_provider_clears_before_provider_post_setup(monkeypatch):
events = []
class PostSetupProvider:
def post_setup(self, hermes_home, config):
events.append("post_setup")
monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("openviking", "local", PostSetupProvider())])
monkeypatch.setattr(memory_setup, "_clear_interactive_transition", lambda: events.append("clear"), raising=False)
monkeypatch.setattr(memory_setup, "_install_dependencies", lambda name: events.append("install"))
monkeypatch.setattr(memory_setup, "get_hermes_home", lambda: "/tmp/hermes-test")
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"memory": {}})
memory_setup.cmd_setup_provider("openviking")
assert events == ["clear", "install", "post_setup"]
def test_cmd_status_prefers_provider_status_config(monkeypatch, capsys):
class StatusProvider:
def get_status_config(self, provider_config):
assert provider_config["endpoint"] == "http://stale.local"
return {
"use_ovcli_config": True,
"ovcli_config_path": "/tmp/ovcli.conf.VPS_ROOT",
"endpoint": "https://vps.example",
"account": "acct",
"user": "alice",
"agent": "hermes",
}
def is_available(self):
return True
config = {
"memory": {
"provider": "openviking",
"openviking": {
"use_ovcli_config": True,
"ovcli_config_path": "/tmp/ovcli.conf.VPS_ROOT",
"endpoint": "http://stale.local",
},
}
}
monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("openviking", "API key / local", StatusProvider())])
memory_setup.cmd_status(SimpleNamespace())
output = capsys.readouterr().out
assert "endpoint: https://vps.example" in output
assert "http://stale.local" not in output
def test_cmd_setup_generic_choice_cancel_writes_nothing(tmp_path, monkeypatch):
class ChoiceProvider:
def __init__(self):
self.save_config = MagicMock()
def get_config_schema(self):
return [{
"key": "mode",
"description": "Mode",
"default": "one",
"choices": ["one", "two"],
}]
provider = ChoiceProvider()
selections = iter([0, _CANCELLED])
save_config = MagicMock()
install_dependencies = MagicMock()
monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("fake", "local", provider)])
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(selections))
monkeypatch.setattr(memory_setup, "_install_dependencies", install_dependencies)
monkeypatch.setattr(memory_setup, "get_hermes_home", lambda: tmp_path)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"memory": {}})
monkeypatch.setattr("hermes_cli.config.save_config", save_config)
memory_setup.cmd_setup(SimpleNamespace())
install_dependencies.assert_called_once_with("fake")
save_config.assert_not_called()
provider.save_config.assert_not_called()
assert not (tmp_path / ".env").exists()

View file

@ -25,7 +25,7 @@ def test_collect_masked_input_shows_feedback_without_echoing_secret():
value, output = _run_collect("secret\n")
assert value == "secret"
assert output == "API key: ******\n"
assert output == "API key: ******\r\n"
assert "secret" not in output
@ -33,7 +33,7 @@ def test_collect_masked_input_handles_backspace():
value, output = _run_collect("sec\x7fret\r")
assert value == "seret"
assert output == "API key: ***\b \b***\n"
assert output == "API key: ***\b \b***\r\n"
assert "secret" not in output
@ -47,7 +47,7 @@ def test_collect_masked_input_raises_keyboard_interrupt():
"API key: ",
)
assert "".join(output) == "API key: \n"
assert "".join(output) == "API key: \r\n"
def test_masked_secret_prompt_falls_back_to_getpass_for_non_tty(monkeypatch):

View file

@ -331,3 +331,108 @@ def test_hosted_policy_locks_to_opt_data(monkeypatch):
assert str(policy.locked_root) == "/opt/data"
assert policy.can_change_path is False
# ---------------------------------------------------------------------------
# Streaming multipart upload (/api/files/upload-stream) — NS-501
# ---------------------------------------------------------------------------
def test_stream_upload_roundtrip(forced_files_client):
"""The multipart endpoint writes raw bytes to disk and reports the entry."""
client, root = forced_files_client
file_path = root / "out" / "backup.zip"
payload = b"PK\x03\x04 not really a zip but binary enough \x00\x01\x02"
created = client.post(
"/api/files/upload-stream",
data={"path": str(file_path), "overwrite": "true"},
files={"file": ("backup.zip", payload, "application/zip")},
)
assert created.status_code == 200, created.text
assert created.json()["entry"]["path"] == str(file_path)
assert created.json()["locked_root"] == str(root)
# Bytes land verbatim — no base64 round-trip, no corruption.
assert file_path.read_bytes() == payload
def test_stream_upload_rejects_oversized_without_clobbering(forced_files_client, monkeypatch):
"""Over-limit uploads return 413 and never overwrite an existing file.
The size cap is enforced while streaming (not after buffering), and the
temp-file + atomic-rename design means a rejected upload leaves any
pre-existing file at the target path untouched.
"""
client, root = forced_files_client
file_path = root / "out" / "big.bin"
# Seed an existing file at the target path.
seeded = client.post(
"/api/files/upload-stream",
data={"path": str(file_path), "overwrite": "true"},
files={"file": ("big.bin", b"original-contents", "application/octet-stream")},
)
assert seeded.status_code == 200
assert file_path.read_bytes() == b"original-contents"
# Shrink the cap so a small payload trips it deterministically.
monkeypatch.setattr(web_server, "_MANAGED_FILE_MAX_BYTES", 8)
rejected = client.post(
"/api/files/upload-stream",
data={"path": str(file_path), "overwrite": "true"},
files={"file": ("big.bin", b"way too many bytes for the cap", "application/octet-stream")},
)
assert rejected.status_code == 413
# The original file must survive a rejected overwrite.
assert file_path.read_bytes() == b"original-contents"
# No stray temp files left behind in the directory.
leftovers = [p.name for p in file_path.parent.iterdir() if ".upload" in p.name]
assert leftovers == [], f"temp upload files leaked: {leftovers}"
def test_stream_upload_respects_overwrite_false(forced_files_client):
client, root = forced_files_client
file_path = root / "keep.txt"
first = client.post(
"/api/files/upload-stream",
data={"path": str(file_path), "overwrite": "true"},
files={"file": ("keep.txt", b"first", "text/plain")},
)
assert first.status_code == 200
conflict = client.post(
"/api/files/upload-stream",
data={"path": str(file_path), "overwrite": "false"},
files={"file": ("keep.txt", b"second", "text/plain")},
)
assert conflict.status_code == 409
assert file_path.read_bytes() == b"first"
def test_stream_upload_stays_under_forced_root(forced_files_client):
"""A relative path with traversal can't escape the locked root."""
client, root = forced_files_client
escaped = client.post(
"/api/files/upload-stream",
data={"path": "../../etc/evil.txt", "overwrite": "true"},
files={"file": ("evil.txt", b"nope", "text/plain")},
)
assert escaped.status_code in (400, 403)
def test_stream_upload_large_file_under_cap_succeeds(forced_files_client, monkeypatch):
"""A multi-chunk payload (larger than the 1 MiB chunk) streams correctly."""
client, root = forced_files_client
file_path = root / "multi-chunk.bin"
# 2.5 MiB exercises the chunked read loop across multiple iterations.
payload = b"x" * (2 * 1024 * 1024 + 512 * 1024)
created = client.post(
"/api/files/upload-stream",
data={"path": str(file_path), "overwrite": "true"},
files={"file": ("multi-chunk.bin", payload, "application/octet-stream")},
)
assert created.status_code == 200
assert file_path.stat().st_size == len(payload)
assert file_path.read_bytes() == payload

View file

@ -15,6 +15,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from hermes_cli.memory_setup import _CANCELLED
from plugins.memory.hindsight import (
HindsightMemoryProvider,
RECALL_SCHEMA,
@ -376,6 +377,61 @@ class TestConfig:
class TestPostSetup:
def test_setup_cancel_at_mode_picker_writes_nothing(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes-home"
user_home = tmp_path / "user-home"
user_home.mkdir()
monkeypatch.setenv("HOME", str(user_home))
monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: hermes_home)
save_config = MagicMock()
which = MagicMock(return_value="/usr/bin/uv")
run = MagicMock()
monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: _CANCELLED)
monkeypatch.setattr("shutil.which", which)
monkeypatch.setattr("subprocess.run", run)
monkeypatch.setattr("builtins.input", MagicMock(side_effect=AssertionError("prompt should not run")))
monkeypatch.setattr("getpass.getpass", MagicMock(side_effect=AssertionError("prompt should not run")))
monkeypatch.setattr("hermes_cli.config.save_config", save_config)
provider = HindsightMemoryProvider()
provider.post_setup(str(hermes_home), {"memory": {"provider": "builtin"}})
save_config.assert_not_called()
which.assert_not_called()
run.assert_not_called()
assert not (hermes_home / ".env").exists()
assert not (hermes_home / "hindsight" / "config.json").exists()
assert not (user_home / ".hindsight" / "profiles" / "hermes.env").exists()
def test_local_embedded_setup_cancel_at_llm_picker_writes_nothing(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes-home"
user_home = tmp_path / "user-home"
user_home.mkdir()
monkeypatch.setenv("HOME", str(user_home))
monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: hermes_home)
selections = iter([1, _CANCELLED]) # local_embedded, then cancel LLM picker
save_config = MagicMock()
which = MagicMock(return_value="/usr/bin/uv")
run = MagicMock()
monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: next(selections))
monkeypatch.setattr("shutil.which", which)
monkeypatch.setattr("subprocess.run", run)
monkeypatch.setattr("builtins.input", MagicMock(side_effect=AssertionError("prompt should not run")))
monkeypatch.setattr("getpass.getpass", MagicMock(side_effect=AssertionError("prompt should not run")))
monkeypatch.setattr("hermes_cli.config.save_config", save_config)
provider = HindsightMemoryProvider()
provider.post_setup(str(hermes_home), {"memory": {"provider": "builtin"}})
save_config.assert_not_called()
which.assert_not_called()
run.assert_not_called()
assert not (hermes_home / ".env").exists()
assert not (hermes_home / "hindsight" / "config.json").exists()
assert not (user_home / ".hindsight" / "profiles" / "hermes.env").exists()
def test_local_embedded_setup_materializes_profile_env(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes-home"
user_home = tmp_path / "user-home"

File diff suppressed because it is too large Load diff

View file

@ -90,6 +90,8 @@ def test_aiagent_forwards_user_id_alt_to_memory_provider():
assert provider.init_kwargs["user_id"] == "open-id"
assert provider.init_kwargs["user_id_alt"] == "union-id"
assert provider.init_kwargs["platform"] == "feishu"
assert "warning_callback" not in provider.init_kwargs
assert "status_callback" not in provider.init_kwargs
class CoreShadowProvider:
@ -132,3 +134,34 @@ def test_core_tool_names_rejected_from_memory_routing_table():
assert "clarify" not in schema_names
assert "delegate_task" not in schema_names
assert "honcho_search" in schema_names
def test_aiagent_forwards_warning_callback_to_cli_memory_provider():
provider = RecordingMemoryProvider()
cfg = {"memory": {"provider": "recording"}, "agent": {}}
with (
patch("hermes_cli.config.load_config", return_value=cfg),
patch("plugins.memory.load_memory_provider", return_value=provider),
patch("agent.model_metadata.get_model_context_length", return_value=204_800),
patch("run_agent.get_tool_definitions", return_value=[]),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("run_agent.OpenAI"),
):
from run_agent import AIAgent
agent = AIAgent(
api_key="test-key-1234567890",
base_url="https://openrouter.ai/api/v1",
quiet_mode=True,
skip_context_files=True,
skip_memory=False,
session_id="sess-cli",
platform="cli",
)
assert agent._memory_manager is not None
assert provider.init_session_id == "sess-cli"
assert provider.init_kwargs["platform"] == "cli"
assert provider.init_kwargs["warning_callback"] == agent._emit_warning
assert provider.init_kwargs["status_callback"] == agent._emit_status

View file

@ -178,6 +178,7 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
"fastapi==0.133.1",
"uvicorn[standard]==0.41.0",
"starlette==1.0.1", # CVE-2026-48710 (BadHost) — keep lazy-install in sync with pyproject [web]
"python-multipart==0.0.20", # FastAPI UploadFile/Form for streaming uploads (NS-501)
),
# Vision image-resize recovery (Pillow). Pillow is now a CORE dependency
# (pyproject `dependencies`), so this entry is a belt-and-suspenders fallback

12
uv.lock generated
View file

@ -1445,6 +1445,7 @@ dependencies = [
{ name = "pydantic" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
{ name = "pyyaml" },
{ name = "requests" },
@ -1469,6 +1470,7 @@ all = [
{ name = "google-auth-httplib2" },
{ name = "google-auth-oauthlib" },
{ name = "mcp" },
{ name = "python-multipart" },
{ name = "simple-term-menu" },
{ name = "starlette" },
{ name = "uvicorn", extra = ["standard"] },
@ -1598,6 +1600,7 @@ termux-all = [
{ name = "google-auth-oauthlib" },
{ name = "honcho-ai" },
{ name = "mcp" },
{ name = "python-multipart" },
{ name = "python-telegram-bot", extra = ["webhooks"] },
{ name = "simple-term-menu" },
{ name = "starlette" },
@ -1613,6 +1616,7 @@ voice = [
]
web = [
{ name = "fastapi" },
{ name = "python-multipart" },
{ name = "starlette" },
{ name = "uvicorn", extra = ["standard"] },
]
@ -1708,6 +1712,8 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" },
{ name = "python-dotenv", specifier = "==1.2.2" },
{ name = "python-multipart", specifier = ">=0.0.9,<1" },
{ name = "python-multipart", marker = "extra == 'web'", specifier = "==0.0.20" },
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" },
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" },
{ name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=2.0.0,<3" },
@ -3311,11 +3317,11 @@ wheels = [
[[package]]
name = "python-multipart"
version = "0.0.27"
version = "0.0.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
]
[[package]]

View file

@ -411,12 +411,21 @@ export const api = {
fetchJSON<ManagedFileReadResponse>(
`/api/files/read?path=${encodeURIComponent(path)}`,
),
uploadFile: (path: string, dataUrl: string, overwrite = true) =>
fetchJSON<ManagedFileWriteResponse>("/api/files/upload", {
uploadFile: (path: string, file: File, overwrite = true) => {
// Stream the raw bytes as multipart/form-data. Do NOT set Content-Type —
// the browser adds the multipart boundary automatically. Sending the file
// as base64 JSON (the old path) inflated the body ~33%, buffered the whole
// file in memory, and 502'd on large backup archives behind the proxy
// (NS-501).
const form = new FormData();
form.append("path", path);
form.append("overwrite", String(overwrite));
form.append("file", file, file.name);
return fetchJSON<ManagedFileWriteResponse>("/api/files/upload-stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, data_url: dataUrl, overwrite }),
}),
body: form,
});
},
createDirectory: (path: string) =>
fetchJSON<ManagedFileWriteResponse>("/api/files/mkdir", {
method: "POST",

View file

@ -58,18 +58,6 @@ function formatBytes(size: number | null): string {
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function readAsDataUrl(file: globalThis.File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
if (typeof reader.result === "string") resolve(reader.result);
else reject(new Error("Could not read file"));
});
reader.addEventListener("error", () => reject(reader.error ?? new Error("Could not read file")));
reader.readAsDataURL(file);
});
}
function downloadDataUrl(dataUrl: string, name: string) {
const link = document.createElement("a");
link.href = dataUrl;
@ -205,8 +193,7 @@ export default function FilesPage() {
setUploading(true);
try {
for (const file of Array.from(files)) {
const dataUrl = await readAsDataUrl(file);
await api.uploadFile(joinPath(activePath, file.name), dataUrl, true);
await api.uploadFile(joinPath(activePath, file.name), file, true);
}
showToast(`${files.length} file${files.length === 1 ? "" : "s"} uploaded`, "success");
await load();