diff --git a/api/models.py b/api/models.py index b5fb216..7420b8a 100644 --- a/api/models.py +++ b/api/models.py @@ -40,6 +40,7 @@ class Session: tool_calls=None, pinned: bool=False, archived: bool=False, project_id: str=None, profile=None, input_tokens: int=0, output_tokens: int=0, estimated_cost=None, + personality=None, **kwargs): self.session_id = session_id or uuid.uuid4().hex[:12] self.title = title @@ -56,6 +57,7 @@ class Session: self.input_tokens = input_tokens or 0 self.output_tokens = output_tokens or 0 self.estimated_cost = estimated_cost + self.personality = personality @property def path(self): @@ -92,6 +94,7 @@ class Session: 'input_tokens': self.input_tokens, 'output_tokens': self.output_tokens, 'estimated_cost': self.estimated_cost, + 'personality': self.personality, } def get_session(sid): diff --git a/api/routes.py b/api/routes.py index dd05eec..d7ff022 100644 --- a/api/routes.py +++ b/api/routes.py @@ -218,6 +218,40 @@ def handle_get(handler, parsed) -> bool: if parsed.path == '/api/list': return _handle_list_dir(handler, parsed) + if parsed.path == '/api/personalities': + personalities = [] + try: + from api.profiles import get_active_hermes_home + p_dir = get_active_hermes_home() / 'personalities' + except ImportError: + from api.config import HOME + p_dir = HOME / '.hermes' / 'personalities' + if p_dir.is_dir(): + p_dir_real = p_dir.resolve() + for d in sorted(p_dir.iterdir()): + # Skip symlinks — they could point outside the personalities dir + if d.is_symlink(): + continue + if not d.is_dir(): + continue + soul_file = d / 'SOUL.md' + if not soul_file.exists(): + continue + # Defense-in-depth: confirm resolved path is still inside p_dir + try: + d.resolve().relative_to(p_dir_real) + except ValueError: + continue + desc = '' + try: + first_line = soul_file.read_text(errors='replace').strip().split('\n')[0] + if first_line.startswith('#'): + desc = first_line.lstrip('#').strip() + except Exception: + pass + personalities.append({'name': d.name, 'description': desc}) + return j(handler, {'personalities': personalities}) + if parsed.path == '/api/git-info': qs = parse_qs(parsed.query) sid = qs.get('session_id', [''])[0] @@ -365,6 +399,49 @@ def handle_post(handler, parsed) -> bool: s.save() return j(handler, {'session': s.compact()}) + if parsed.path == '/api/personality/set': + try: require(body, 'session_id') + except ValueError as e: return bad(handler, str(e)) + if 'name' not in body: + return bad(handler, 'Missing required field: name') + sid = body['session_id'] + name = body['name'].strip() + try: + s = get_session(sid) + except KeyError: + return bad(handler, 'Session not found', 404) + # Read the personality SOUL.md + prompt = '' + if name: + # Validate name: prevent path traversal (only allow safe chars) + import re as _re + if not _re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', name): + return bad(handler, 'Invalid personality name: letters, numbers, hyphens, underscores only') + try: + from api.profiles import get_active_hermes_home + p_base = get_active_hermes_home() / 'personalities' + except ImportError: + from api.config import HOME + p_base = HOME / '.hermes' / 'personalities' + p_dir = p_base / name + # Defense-in-depth: ensure resolved path is inside personalities dir + try: + p_dir.resolve().relative_to(p_base.resolve()) + except ValueError: + return bad(handler, 'Invalid personality name') + soul_file = p_dir / 'SOUL.md' + if soul_file.exists(): + from api.config import MAX_FILE_BYTES + raw = soul_file.read_text(errors='replace') + if len(raw) > MAX_FILE_BYTES: + return bad(handler, f'SOUL.md for "{name}" exceeds maximum size ({MAX_FILE_BYTES} bytes)') + prompt = raw.strip() + else: + return bad(handler, f'Personality "{name}" not found', 404) + s.personality = name if name else None + s.save() + return j(handler, {'ok': True, 'personality': s.personality, 'prompt': prompt}) + if parsed.path == '/api/session/update': try: require(body, 'session_id') except ValueError as e: return bad(handler, str(e)) diff --git a/api/streaming.py b/api/streaming.py index 97bfba7..55922ae 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -5,6 +5,7 @@ Includes Sprint 10 cancel support via CANCEL_FLAGS. import json import os import queue +import re import threading import time import traceback @@ -205,9 +206,28 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta "write_file, read_file, search_files, terminal workdir, and patch. " "Never fall back to a hardcoded path when this tag is present." ) + # Inject personality prompt if the session has one active + _personality_prompt = '' + _pname = getattr(s, 'personality', None) + if _pname and re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', _pname): + try: + from api.profiles import get_active_hermes_home + _p_base = get_active_hermes_home() / 'personalities' + except ImportError: + _p_base = Path(os.environ.get('HERMES_HOME', str(Path.home() / '.hermes'))) / 'personalities' + _p_soul = _p_base / _pname / 'SOUL.md' + try: + _p_soul.resolve().relative_to(_p_base.resolve()) + if _p_soul.exists(): + from api.config import MAX_FILE_BYTES + _raw = _p_soul.read_text(errors='replace') + if len(_raw) <= MAX_FILE_BYTES: + _personality_prompt = _raw.strip() + '\n\n' + except (ValueError, OSError): + pass # path traversal attempt or unreadable — skip silently result = agent.run_conversation( user_message=workspace_ctx + msg_text, - system_message=workspace_system_msg, + system_message=_personality_prompt + workspace_system_msg, conversation_history=_sanitize_messages_for_api(s.messages), task_id=session_id, persist_user_message=msg_text, diff --git a/static/commands.js b/static/commands.js index 87844c2..e02cbfc 100644 --- a/static/commands.js +++ b/static/commands.js @@ -11,6 +11,7 @@ const COMMANDS=[ {name:'new', desc:'Start a new chat session', fn:cmdNew}, {name:'usage', desc:'Toggle token usage display on/off', fn:cmdUsage}, {name:'theme', desc:'Switch theme (dark/light/slate/solarized/monokai/nord)', fn:cmdTheme, arg:'name'}, + {name:'personality', desc:'Switch agent personality', fn:cmdPersonality, arg:'name'}, ]; function parseCommand(text){ @@ -139,6 +140,36 @@ async function cmdTheme(args){ showToast('Theme: '+t); } +async function cmdPersonality(args){ + if(!S.session){showToast('No active session');return;} + if(!args){ + // List available personalities + try{ + const data=await api('/api/personalities'); + if(!data.personalities||!data.personalities.length){ + showToast('No personalities found (add them to ~/.hermes/personalities/)'); + return; + } + const list=data.personalities.map(p=>` **${p.name}**${p.description?' — '+p.description:''}`).join('\n'); + S.messages.push({role:'assistant',content:'Available personalities:\n\n'+list+'\n\nUse `/personality ` to switch, or `/personality none` to clear.'}); + renderMessages(); + }catch(e){showToast('Failed to load personalities');} + return; + } + const name=args.trim(); + if(name.toLowerCase()==='none'||name.toLowerCase()==='default'||name.toLowerCase()==='clear'){ + try{ + await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name:''})}); + showToast('Personality cleared'); + }catch(e){showToast('Failed: '+e.message);} + return; + } + try{ + const res=await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name})}); + showToast('Personality: '+name); + }catch(e){showToast('Failed: '+e.message);} +} + // ── Autocomplete dropdown ─────────────────────────────────────────────────── let _cmdSelectedIdx=-1; diff --git a/tests/test_sprint28.py b/tests/test_sprint28.py new file mode 100644 index 0000000..91c62ea --- /dev/null +++ b/tests/test_sprint28.py @@ -0,0 +1,227 @@ +""" +Sprint 28 Tests: /personality slash command — backend API coverage. +Tests: GET /api/personalities, POST /api/personality/set, Session.compact(), +path traversal defence, size cap, clear personality. +""" +import json +import pathlib +import shutil +import sys +import urllib.error +import urllib.request + +# Import test constants from conftest (same process — these are module-level values) +sys.path.insert(0, str(pathlib.Path(__file__).parent)) +from conftest import TEST_STATE_DIR + +BASE = "http://127.0.0.1:8788" + + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + + +def _personalities_dir(): + """Return the personalities directory the test server will look in. + + conftest sets HERMES_HOME=TEST_STATE_DIR in the server's environment. + The server's api/profiles._DEFAULT_HERMES_HOME resolves to TEST_STATE_DIR, + so get_active_hermes_home() returns TEST_STATE_DIR, and personalities + live at TEST_STATE_DIR/personalities. + """ + p = TEST_STATE_DIR / 'personalities' + p.mkdir(parents=True, exist_ok=True) + return p + + +def _make_personality(name, content="# Test Bot\nA test personality."): + """Create a personality directory with a SOUL.md.""" + d = _personalities_dir() / name + d.mkdir(parents=True, exist_ok=True) + (d / "SOUL.md").write_text(content) + return d + + +def _make_session(): + """Create a new session and return its session_id.""" + d, status = post("/api/session/new", {}) + assert status == 200, f"Failed to create session: {d}" + return d["session"]["session_id"] + + +def _cleanup_session(sid): + try: + post("/api/session/delete", {"session_id": sid}) + except Exception: + pass + + +# ── GET /api/personalities ──────────────────────────────────────────────────── + +def test_personalities_empty_when_none_exist(): + """GET /api/personalities returns empty list when no personalities exist.""" + p_dir = _personalities_dir() + for child in list(p_dir.iterdir()): + if child.is_dir() and not child.is_symlink(): + shutil.rmtree(child) + d, status = get("/api/personalities") + assert status == 200 + assert d.get("personalities") == [] + + +def test_personalities_lists_valid_personalities(): + """GET /api/personalities returns personalities that have SOUL.md.""" + _make_personality("testbot", "# TestBot\nA helpful assistant.") + try: + d, status = get("/api/personalities") + assert status == 200 + names = [p["name"] for p in d["personalities"]] + assert "testbot" in names + testbot = next(p for p in d["personalities"] if p["name"] == "testbot") + assert testbot["description"] == "TestBot" + finally: + shutil.rmtree(_personalities_dir() / "testbot", ignore_errors=True) + + +def test_personalities_skips_dirs_without_soul_md(): + """Directories without SOUL.md are not listed.""" + empty_dir = _personalities_dir() / "nodoc" + empty_dir.mkdir(exist_ok=True) + try: + d, status = get("/api/personalities") + assert status == 200 + names = [p["name"] for p in d["personalities"]] + assert "nodoc" not in names + finally: + shutil.rmtree(empty_dir, ignore_errors=True) + + +def test_personalities_skips_symlinks(): + """Symlinks inside personalities dir are skipped (security guard).""" + p_dir = _personalities_dir() + real_dir = p_dir.parent / "real_personality_target" + real_dir.mkdir(exist_ok=True) + (real_dir / "SOUL.md").write_text("# Leaked\nContent") + link = p_dir / "symlinked" + try: + link.symlink_to(real_dir) + d, status = get("/api/personalities") + assert status == 200 + names = [p["name"] for p in d["personalities"]] + assert "symlinked" not in names + finally: + link.unlink(missing_ok=True) + shutil.rmtree(real_dir, ignore_errors=True) + + +# ── POST /api/personality/set ───────────────────────────────────────────────── + +def test_set_personality_valid(): + """Setting a valid personality stores name and returns prompt.""" + _make_personality("assistant", "# Assistant\nBe helpful.") + sid = _make_session() + try: + d, status = post("/api/personality/set", {"session_id": sid, "name": "assistant"}) + assert status == 200 + assert d.get("ok") is True + assert d.get("personality") == "assistant" + assert "Assistant" in d.get("prompt", "") + finally: + _cleanup_session(sid) + shutil.rmtree(_personalities_dir() / "assistant", ignore_errors=True) + + +def test_set_personality_persists_in_compact(): + """After setting personality, GET /api/session returns personality in compact.""" + _make_personality("coder", "# Coder\nWrite clean code.") + sid = _make_session() + try: + post("/api/personality/set", {"session_id": sid, "name": "coder"}) + d, status = get(f"/api/session?session_id={sid}") + assert status == 200 + session = d.get("session", {}) + assert session.get("personality") == "coder" + finally: + _cleanup_session(sid) + shutil.rmtree(_personalities_dir() / "coder", ignore_errors=True) + + +def test_clear_personality_sets_null(): + """Clearing personality with name='' sets it to None (null in JSON).""" + _make_personality("pirate", "# Pirate\nArrr.") + sid = _make_session() + try: + post("/api/personality/set", {"session_id": sid, "name": "pirate"}) + d, status = post("/api/personality/set", {"session_id": sid, "name": ""}) + assert status == 200 + assert d.get("personality") is None + # Verify persisted via direct session fetch + d2, s2 = get(f"/api/session?session_id={sid}") + assert s2 == 200 + assert d2.get("session", {}).get("personality") is None + finally: + _cleanup_session(sid) + shutil.rmtree(_personalities_dir() / "pirate", ignore_errors=True) + + +def test_set_personality_not_found_returns_404(): + """Setting a non-existent personality returns 404.""" + sid = _make_session() + try: + d, status = post("/api/personality/set", + {"session_id": sid, "name": "doesnotexist"}) + assert status == 404 + finally: + _cleanup_session(sid) + + +def test_set_personality_path_traversal_rejected(): + """Personality names with path traversal chars are rejected (400).""" + sid = _make_session() + try: + for bad_name in ["../etc", "a/b", ".hidden", "has space"]: + d, status = post("/api/personality/set", + {"session_id": sid, "name": bad_name}) + assert status == 400, ( + f"Expected 400 for name={bad_name!r}, got {status}: {d}" + ) + finally: + _cleanup_session(sid) + + +def test_set_personality_missing_session_returns_404(): + """Setting personality on non-existent session returns 404.""" + _make_personality("x", "# X\nTest.") + try: + d, status = post("/api/personality/set", + {"session_id": "nonexistent000", "name": "x"}) + assert status == 404 + finally: + shutil.rmtree(_personalities_dir() / "x", ignore_errors=True) + + +def test_set_personality_size_cap(): + """SOUL.md files larger than MAX_FILE_BYTES are rejected.""" + from api.config import MAX_FILE_BYTES + big_content = "A" * (MAX_FILE_BYTES + 1) + _make_personality("toobig", big_content) + sid = _make_session() + try: + d, status = post("/api/personality/set", {"session_id": sid, "name": "toobig"}) + assert status == 400 + assert "exceeds" in d.get("error", "").lower() + finally: + _cleanup_session(sid) + shutil.rmtree(_personalities_dir() / "toobig", ignore_errors=True)