Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd598c896a | ||
|
|
58eb6e7fd5 | ||
|
|
76cdfb69e0 | ||
|
|
4622b64ca9 | ||
|
|
89891c65c8 | ||
|
|
173261c428 | ||
|
|
863dc4e938 | ||
|
|
4407c3097b | ||
|
|
71dd691ed0 | ||
|
|
9f3b2e113e | ||
|
|
e8a8fceb26 | ||
|
|
e1c2e7e3d6 |
@@ -26,3 +26,6 @@
|
||||
|
||||
# Path to your Hermes config.yaml (for toolsets and model config)
|
||||
# HERMES_CONFIG_PATH=~/.hermes/config.yaml
|
||||
|
||||
# Display name for the assistant in the UI (default: Hermes)
|
||||
# HERMES_WEBUI_BOT_NAME=Hermes
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -5,6 +5,31 @@
|
||||
|
||||
---
|
||||
|
||||
## [v0.37.0] /personality command, model prefix routing fix, tool card reload fix
|
||||
*April 6, 2026 | 465 tests*
|
||||
|
||||
### Features
|
||||
- **`/personality` slash command.** Set a per-session agent personality from `~/.hermes/personalities/<name>/SOUL.md`. The personality prompt is prepended to the system message for every turn. Use `/personality <name>` to activate, `/personality none` to clear, `/personality` (no args) to list available personalities. Backend: `GET /api/personalities`, `POST /api/personality/set`. (PR #143)
|
||||
|
||||
### Bug Fixes
|
||||
- **Model dropdown routes non-default provider models correctly (#138).** When the active provider is `anthropic` and you pick a `minimax` model, its ID is now prefixed `minimax/MiniMax-M2.7` so `resolve_model_provider()` can route it through OpenRouter. Guards added: `active_provider=None` prevents all-providers-prefixed, case is normalised, shared `_PROVIDER_MODELS` list is no longer mutated by the default_model injector. (PR #142)
|
||||
- **Tool call cards persist correctly after page reload.** The reload rendering logic now anchors cards AFTER the triggering assistant row (not before the next one), handles multi-step chains sharing a filtered anchor in chronological order, and filters fallback anchor to assistant rows only. (PR #141)
|
||||
|
||||
---
|
||||
|
||||
## [v0.36.3] Configurable Assistant Name
|
||||
*April 6, 2026 | 449 tests*
|
||||
|
||||
### Features
|
||||
- **Configurable bot name.** New "Assistant Name" field in Settings panel.
|
||||
Display name updates throughout the UI: sidebar, topbar, message roles,
|
||||
login page, browser tab title, and composer placeholder. Defaults to
|
||||
"Hermes". Configurable via settings or `HERMES_WEBUI_BOT_NAME` env var.
|
||||
Server-side sanitization prevents empty names and escapes HTML for the
|
||||
login page. (PR #135, based on #131 by @TaraTheStar)
|
||||
|
||||
---
|
||||
|
||||
## [v0.36.2] OpenRouter model routing fix
|
||||
*April 5, 2026 | 440 tests*
|
||||
|
||||
|
||||
@@ -1164,7 +1164,7 @@ New test cases in `tests/test_sprint26.py`:
|
||||
---
|
||||
|
||||
*Last updated: April 5, 2026*
|
||||
*Current version: v0.36 | 433 tests*
|
||||
*Current version: v0.36.2 | 440 tests*
|
||||
*Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)*
|
||||
*Horizon sprint: Sprint 25 (macOS Desktop Application)*
|
||||
*Docs sweep policy: update markdown proactively during PR reviews and after significant releases*
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
> Prerequisites: SSH tunnel is active on port 8786. Open http://localhost:8786 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8786/health should return {"status":"ok"}.
|
||||
>
|
||||
> Automated tests: 433 total (433 passing, 0 failures)
|
||||
> Automated tests: 465 total (461 passing, 4 known isolation failures in test_sprint28)
|
||||
> Run: `pytest tests/ -v --timeout=60`
|
||||
|
||||
---
|
||||
|
||||
@@ -585,9 +585,26 @@ def get_available_models() -> dict:
|
||||
'models': [{'id': m['id'], 'label': m['label']} for m in _FALLBACK_MODELS],
|
||||
})
|
||||
elif pid in _PROVIDER_MODELS:
|
||||
# For non-default providers, prefix model IDs with provider name
|
||||
# so resolve_model_provider() can route them correctly (e.g.
|
||||
# \"minimax/MiniMax-M2.7\" instead of bare \"MiniMax-M2.7\").
|
||||
# The default provider's models keep bare names for direct API routing.
|
||||
# Guard: only prefix when we have a confirmed active_provider, and
|
||||
# normalise case before comparing (config.yaml may use 'Anthropic').
|
||||
raw_models = _PROVIDER_MODELS[pid]
|
||||
_active = (active_provider or '').lower()
|
||||
if _active and pid != _active:
|
||||
# Shallow copy — don't mutate the shared _PROVIDER_MODELS list.
|
||||
# Bare IDs get prefixed; already-prefixed IDs pass through as-is.
|
||||
models = []
|
||||
for m in raw_models:
|
||||
mid = m['id']
|
||||
models.append({'id': mid if '/' in mid else f'{pid}/{mid}', 'label': m['label']})
|
||||
else:
|
||||
models = list(raw_models) # shallow copy to protect against insert() mutations
|
||||
groups.append({
|
||||
'provider': provider_name,
|
||||
'models': _PROVIDER_MODELS[pid],
|
||||
'models': models,
|
||||
})
|
||||
else:
|
||||
# Unknown provider -- use auto-detected models if available,
|
||||
@@ -680,6 +697,7 @@ _SETTINGS_DEFAULTS = {
|
||||
'sync_to_insights': False, # mirror WebUI token usage to state.db for /insights
|
||||
'check_for_updates': True, # check if webui/agent repos are behind upstream
|
||||
'theme': 'dark', # active UI theme name (no enum gate -- allows custom themes)
|
||||
'bot_name': os.getenv('HERMES_WEBUI_BOT_NAME', 'Hermes'), # display name for the assistant
|
||||
'password_hash': None, # SHA-256 hash; None = auth disabled
|
||||
}
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Hermes Web UI -- Route handlers for GET and POST endpoints.
|
||||
Extracted from server.py (Sprint 11) so server.py is a thin shell.
|
||||
"""
|
||||
import html as _html
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
@@ -56,7 +57,7 @@ except ImportError:
|
||||
# ── Login page (self-contained, no external deps) ────────────────────────────
|
||||
_LOGIN_PAGE_HTML = '''<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Hermes — Sign in</title>
|
||||
<title>{{BOT_NAME}} — Sign in</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:#1a1a2e;color:#e8e8f0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
|
||||
@@ -79,8 +80,8 @@ button:hover{background:rgba(124,185,255,.25)}
|
||||
.err{color:#e94560;font-size:12px;margin-top:10px;display:none}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<div class="logo">H</div>
|
||||
<h1>Hermes</h1>
|
||||
<div class="logo">{{BOT_NAME_INITIAL}}</div>
|
||||
<h1>{{BOT_NAME}}</h1>
|
||||
<p class="sub">Enter your password to continue</p>
|
||||
<form onsubmit="doLogin(event);return false">
|
||||
<input type="password" id="pw" placeholder="Password" autofocus
|
||||
@@ -116,7 +117,9 @@ def handle_get(handler, parsed) -> bool:
|
||||
content_type='text/html; charset=utf-8')
|
||||
|
||||
if parsed.path == '/login':
|
||||
return t(handler, _LOGIN_PAGE_HTML, content_type='text/html; charset=utf-8')
|
||||
_bn = _html.escape(load_settings().get('bot_name') or 'Hermes')
|
||||
_page = _LOGIN_PAGE_HTML.replace('{{BOT_NAME}}', _bn).replace('{{BOT_NAME_INITIAL}}', _bn[0].upper())
|
||||
return t(handler, _page, content_type='text/html; charset=utf-8')
|
||||
|
||||
if parsed.path == '/api/auth/status':
|
||||
from api.auth import is_auth_enabled, parse_cookie, verify_session
|
||||
@@ -215,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]
|
||||
@@ -362,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))
|
||||
@@ -523,6 +603,8 @@ def handle_post(handler, parsed) -> bool:
|
||||
|
||||
# ── Settings (POST) ──
|
||||
if parsed.path == '/api/settings':
|
||||
if 'bot_name' in body:
|
||||
body['bot_name'] = (str(body['bot_name']) or '').strip() or 'Hermes'
|
||||
saved = save_settings(body)
|
||||
saved.pop('password_hash', None) # never expose hash to client
|
||||
return j(handler, saved)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -306,10 +306,23 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
|
||||
};
|
||||
})();
|
||||
|
||||
function applyBotName(){
|
||||
const name=window._botName||'Hermes';
|
||||
document.title=name;
|
||||
const sidebarH1=document.querySelector('.sidebar-header h1');
|
||||
if(sidebarH1) sidebarH1.textContent=name;
|
||||
const logo=document.querySelector('.sidebar-header .logo');
|
||||
if(logo) logo.textContent=name.charAt(0).toUpperCase();
|
||||
const topbarTitle=$('topbarTitle');
|
||||
if(topbarTitle && (!S.session)) topbarTitle.textContent=name;
|
||||
const msg=$('msg');
|
||||
if(msg) msg.placeholder='Message '+name+'\u2026';
|
||||
}
|
||||
|
||||
(async()=>{
|
||||
// Load send key preference
|
||||
let _bootSettings={};
|
||||
try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;_bootSettings={check_for_updates:false};}
|
||||
try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;window._botName=s.bot_name||'Hermes';const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);applyBotName();}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;window._botName='Hermes';_bootSettings={check_for_updates:false};}
|
||||
// Non-blocking update check (fire-and-forget, once per tab session)
|
||||
// ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
|
||||
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
|
||||
|
||||
@@ -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 <name>` 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;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.36.2</div></div></div>
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.37.0</div></div></div>
|
||||
<div class="sidebar-nav">
|
||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||
@@ -372,6 +372,11 @@
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsBotName">Assistant Name</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Display name for the assistant throughout the UI. Defaults to Hermes.</div>
|
||||
<input type="text" id="settingsBotName" placeholder="Hermes" maxlength="64" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
</div>
|
||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||
<label for="settingsPassword">Access Password</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
||||
|
||||
@@ -93,8 +93,9 @@ async function send(){
|
||||
assistantRow=document.createElement('div');assistantRow.className='msg-row';
|
||||
assistantBody=document.createElement('div');assistantBody.className='msg-body';
|
||||
const role=document.createElement('div');role.className='msg-role assistant';
|
||||
const icon=document.createElement('div');icon.className='role-icon assistant';icon.textContent='H';
|
||||
const lbl=document.createElement('span');lbl.style.fontSize='12px';lbl.textContent='Hermes';
|
||||
const _bn=window._botName||'Hermes';
|
||||
const icon=document.createElement('div');icon.className='role-icon assistant';icon.textContent=_bn.charAt(0).toUpperCase();
|
||||
const lbl=document.createElement('span');lbl.style.fontSize='12px';lbl.textContent=_bn;
|
||||
role.appendChild(icon);role.appendChild(lbl);
|
||||
assistantRow.appendChild(role);assistantRow.appendChild(assistantBody);
|
||||
$('msgInner').appendChild(assistantRow);
|
||||
|
||||
@@ -1009,6 +1009,9 @@ async function loadSettingsPanel(){
|
||||
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||
const updateCb=$('settingsCheckUpdates');
|
||||
if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||
// Bot name
|
||||
const botNameField=$('settingsBotName');
|
||||
if(botNameField){botNameField.value=settings.bot_name||'Hermes';botNameField.addEventListener('input',_markSettingsDirty,{once:false});}
|
||||
// Password field: always blank (we don't send hash back)
|
||||
const pwField=$('settingsPassword');
|
||||
if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});}
|
||||
@@ -1042,6 +1045,8 @@ async function saveSettings(andClose){
|
||||
body.show_cli_sessions=showCliSessions;
|
||||
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
|
||||
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
|
||||
const botName=(($('settingsBotName')||{}).value||'').trim();
|
||||
body.bot_name=botName||'Hermes';
|
||||
// Password: only act if the field has content; blank = leave auth unchanged
|
||||
if(pw && pw.trim()){
|
||||
try{
|
||||
@@ -1060,6 +1065,8 @@ async function saveSettings(andClose){
|
||||
window._sendKey=sendKey||'enter';
|
||||
window._showTokenUsage=showTokenUsage;
|
||||
window._showCliSessions=showCliSessions;
|
||||
window._botName=body.bot_name;
|
||||
if(typeof applyBotName==='function') applyBotName();
|
||||
_settingsDirty=false; _settingsThemeOnOpen=theme;
|
||||
const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none';
|
||||
renderMessages();
|
||||
|
||||
@@ -45,7 +45,7 @@ async function loadSession(sid){
|
||||
if(tc&&tc.name) appendLiveToolCard(tc);
|
||||
}
|
||||
syncTopbar();await loadDir('.');renderMessages();appendThinking();
|
||||
setBusy(true);setStatus('Hermes is thinking\u2026');
|
||||
setBusy(true);setStatus((window._botName||'Hermes')+' is thinking\u2026');
|
||||
startApprovalPolling(sid);
|
||||
}else{
|
||||
MSG_QUEUE.length=0;updateQueueBadge(); // clear queue for the viewed session
|
||||
@@ -429,7 +429,7 @@ async function deleteSession(sid){
|
||||
if(remaining.sessions&&remaining.sessions.length){
|
||||
await loadSession(remaining.sessions[0].session_id);
|
||||
}else{
|
||||
$('topbarTitle').textContent='Hermes';
|
||||
$('topbarTitle').textContent=window._botName||'Hermes';
|
||||
$('topbarMeta').textContent='Start a new conversation';
|
||||
$('msgInner').innerHTML='';
|
||||
$('emptyState').style.display='';
|
||||
|
||||
51
static/ui.js
51
static/ui.js
@@ -237,7 +237,7 @@ function setStatus(t){
|
||||
txt.textContent=t;
|
||||
bar.style.display='';
|
||||
// Show dismiss X only for static/error messages, not transient busy ones
|
||||
const transient = t.endsWith('…') || t === 'Hermes is thinking…';
|
||||
const transient = t.endsWith('…') || t === (window._botName||'Hermes')+' is thinking\u2026';
|
||||
if(dismiss)dismiss.style.display=(!transient && !S.busy)?'inline':'none';
|
||||
}
|
||||
}
|
||||
@@ -402,7 +402,7 @@ async function checkInflightOnBoot(sid) {
|
||||
|
||||
function syncTopbar(){
|
||||
if(!S.session){
|
||||
document.title='Hermes';
|
||||
document.title=window._botName||'Hermes';
|
||||
// Show default workspace name even without a session
|
||||
const sidebarName=$('sidebarWsName');
|
||||
if(sidebarName && sidebarName.textContent==='Workspace'){
|
||||
@@ -412,7 +412,7 @@ function syncTopbar(){
|
||||
}
|
||||
const sessionTitle=S.session.title||'Untitled';
|
||||
$('topbarTitle').textContent=sessionTitle;
|
||||
document.title=sessionTitle+' \u2014 Hermes';
|
||||
document.title=sessionTitle+' \u2014 '+(window._botName||'Hermes');
|
||||
const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool');
|
||||
$('topbarMeta').textContent=`${vis.length} messages`;
|
||||
// If a profile switch just happened, apply its model rather than the session's stale value.
|
||||
@@ -505,7 +505,8 @@ function renderMessages(){
|
||||
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="Regenerate response" onclick="regenerateResponse(this)">↻</button>` : '';
|
||||
const tsVal=m._ts||m.timestamp;
|
||||
const tsTitle=tsVal?new Date(tsVal*1000).toLocaleString():'';
|
||||
row.innerHTML=`<div class="msg-role ${m.role}" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon ${m.role}">${isUser?'Y':'H'}</div><span style="font-size:12px">${isUser?'You':'Hermes'}</span>${tsTitle?`<span class="msg-time">${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span>`:''}<span class="msg-actions">${editBtn}<button class="msg-copy-btn msg-action-btn" title="Copy" onclick="copyMsg(this)">📋</button>${retryBtn}</span></div>${filesHtml}<div class="msg-body">${bodyHtml}</div>`;
|
||||
const _bn=window._botName||'Hermes';
|
||||
row.innerHTML=`<div class="msg-role ${m.role}" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon ${m.role}">${isUser?'Y':esc(_bn.charAt(0).toUpperCase())}</div><span style="font-size:12px">${isUser?'You':esc(_bn)}</span>${tsTitle?`<span class="msg-time">${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span>`:''}<span class="msg-actions">${editBtn}<button class="msg-copy-btn msg-action-btn" title="Copy" onclick="copyMsg(this)">📋</button>${retryBtn}</span></div>${filesHtml}<div class="msg-body">${bodyHtml}</div>`;
|
||||
row.dataset.rawText = String(content).trim();
|
||||
inner.appendChild(row);
|
||||
}
|
||||
@@ -521,18 +522,35 @@ function renderMessages(){
|
||||
byAssistant[key].push(tc);
|
||||
}
|
||||
const allRows = Array.from(inner.querySelectorAll('.msg-row[data-msg-idx]'));
|
||||
// Track the last inserted node per anchor so back-to-back groups for the
|
||||
// same (filtered) anchor row are inserted in chronological order.
|
||||
const anchorInsertAfter = new Map();
|
||||
for(const [key, cards] of Object.entries(byAssistant)){
|
||||
const aIdx = parseInt(key);
|
||||
let insertBefore = null;
|
||||
if(aIdx === -1){
|
||||
for(let i=allRows.length-1;i>=0;i--){
|
||||
const ri=parseInt(allRows[i].dataset.msgIdx||'-1',10);
|
||||
if(ri>=0&&S.messages[ri]&&S.messages[ri].role==='assistant'){insertBefore=allRows[i];break;}
|
||||
}
|
||||
} else {
|
||||
// Find the right insertion point: cards go AFTER the assistant message
|
||||
// that triggered them. We look for the row at aIdx, or the nearest
|
||||
// visible ASSISTANT row at or before aIdx (the assistant message may be
|
||||
// filtered out if it contained only tool_use blocks with no text response).
|
||||
let anchorRow = null;
|
||||
if(aIdx >= 0){
|
||||
// First: exact match for the assistant row
|
||||
for(const r of allRows){
|
||||
const ri=parseInt(r.dataset.msgIdx||'-1');
|
||||
if(ri>aIdx&&S.messages[ri]&&S.messages[ri].role==='assistant'){insertBefore=r;break;}
|
||||
if(ri===aIdx){anchorRow=r;break;}
|
||||
}
|
||||
// Fallback: nearest visible ASSISTANT row at or before aIdx
|
||||
if(!anchorRow){
|
||||
for(let i=allRows.length-1;i>=0;i--){
|
||||
const ri=parseInt(allRows[i].dataset.msgIdx||'-1');
|
||||
if(ri<=aIdx&&S.messages[ri]&&S.messages[ri].role==='assistant'){anchorRow=allRows[i];break;}
|
||||
}
|
||||
}
|
||||
}
|
||||
// aIdx === -1 or no assistant anchor found: attach after the last assistant row
|
||||
if(!anchorRow){
|
||||
for(let i=allRows.length-1;i>=0;i--){
|
||||
const ri=parseInt(allRows[i].dataset.msgIdx||'-1',10);
|
||||
if(ri>=0&&S.messages[ri]&&S.messages[ri].role==='assistant'){anchorRow=allRows[i];break;}
|
||||
}
|
||||
}
|
||||
const frag=document.createDocumentFragment();
|
||||
@@ -553,8 +571,15 @@ function renderMessages(){
|
||||
toggle.appendChild(collapseBtn);
|
||||
frag.insertBefore(toggle,frag.firstChild);
|
||||
}
|
||||
if(insertBefore) inner.insertBefore(frag,insertBefore);
|
||||
// Insert after the anchor row (or after any previously inserted group for
|
||||
// the same anchor), preserving chronological order for multi-step chains.
|
||||
const insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow;
|
||||
const refNode = insertAfterNode ? insertAfterNode.nextSibling : null;
|
||||
if(refNode) inner.insertBefore(frag,refNode);
|
||||
else inner.appendChild(frag);
|
||||
// Record the last child we inserted so the next group for this anchor
|
||||
// goes after it rather than back at anchorRow.nextSibling.
|
||||
anchorInsertAfter.set(anchorRow, inner.lastChild);
|
||||
}
|
||||
}
|
||||
// Render usage badge on the last assistant message row (if enabled and usage data exists)
|
||||
|
||||
@@ -98,3 +98,83 @@ def test_empty_model_returns_config_defaults():
|
||||
)
|
||||
assert model == ''
|
||||
assert provider == 'anthropic'
|
||||
|
||||
|
||||
# ── Non-default provider prefix routing (Issue #138) ────────────────────
|
||||
|
||||
def test_prefixed_non_default_provider_routes_through_openrouter():
|
||||
"""minimax/MiniMax-M2.7 with anthropic as default should route via openrouter."""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
'minimax/MiniMax-M2.7', provider='anthropic',
|
||||
)
|
||||
assert model == 'minimax/MiniMax-M2.7'
|
||||
assert provider == 'openrouter'
|
||||
|
||||
|
||||
def test_prefixed_non_default_provider_zai():
|
||||
"""zai/GLM-5 with openai as default should route via openrouter."""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
'zai/GLM-5', provider='openai',
|
||||
)
|
||||
assert model == 'zai/GLM-5'
|
||||
assert provider == 'openrouter'
|
||||
|
||||
|
||||
# ── get_available_models() prefix behaviour ───────────────────────────────
|
||||
|
||||
def _available_models_with_provider(provider):
|
||||
"""Helper: temporarily set active_provider in auth store simulation via config.cfg."""
|
||||
old_cfg = dict(config.cfg)
|
||||
config.cfg['model'] = {'provider': provider}
|
||||
try:
|
||||
return config.get_available_models()
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
|
||||
|
||||
def test_non_default_provider_models_are_prefixed():
|
||||
"""With anthropic as default, minimax model IDs should be prefixed 'minimax/...'."""
|
||||
result = _available_models_with_provider('anthropic')
|
||||
groups = {g['provider']: g['models'] for g in result['groups']}
|
||||
if 'MiniMax' in groups:
|
||||
for m in groups['MiniMax']:
|
||||
assert m['id'].startswith('minimax/'), (
|
||||
f"Expected minimax/ prefix, got: {m['id']!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_default_provider_models_not_prefixed():
|
||||
"""The active provider's _PROVIDER_MODELS entries remain bare (no prefix added)."""
|
||||
import api.config as _cfg
|
||||
# The bare IDs as stored in _PROVIDER_MODELS (e.g. 'claude-sonnet-4.6')
|
||||
raw_anthropic_ids = {m['id'] for m in _cfg._PROVIDER_MODELS.get('anthropic', [])}
|
||||
result = _available_models_with_provider('anthropic')
|
||||
groups = {g['provider']: g['models'] for g in result['groups']}
|
||||
if 'Anthropic' in groups:
|
||||
returned_ids = {m['id'] for m in groups['Anthropic']}
|
||||
# Every bare _PROVIDER_MODELS ID must still appear bare (not turned into 'anthropic/...')
|
||||
for bare_id in raw_anthropic_ids:
|
||||
assert bare_id in returned_ids, (
|
||||
f"_PROVIDER_MODELS entry '{bare_id}' is missing from the Anthropic group "
|
||||
f"(returned: {sorted(returned_ids)})"
|
||||
)
|
||||
|
||||
|
||||
def test_no_active_provider_models_not_prefixed():
|
||||
"""With no confirmed active_provider, models should not be prefixed."""
|
||||
old_cfg = dict(config.cfg)
|
||||
config.cfg['model'] = {} # no provider set
|
||||
try:
|
||||
result = config.get_available_models()
|
||||
for g in result['groups']:
|
||||
for m in g['models']:
|
||||
# No model should have a double-prefix like 'minimax/minimax/...'
|
||||
parts = m['id'].split('/')
|
||||
if len(parts) >= 2:
|
||||
assert parts[0] != parts[1], (
|
||||
f"Double-prefix detected: {m['id']!r}"
|
||||
)
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
|
||||
136
tests/test_sprint27.py
Normal file
136
tests/test_sprint27.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Sprint 27 Tests: configurable assistant display name (bot_name).
|
||||
Tests cover settings API round-trip, empty/missing input defaults,
|
||||
login page rendering, and server-side sanitization.
|
||||
"""
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
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 get_raw(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return r.read().decode(), 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
|
||||
|
||||
|
||||
# ── Default value ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_settings_default_bot_name():
|
||||
"""GET /api/settings should return bot_name defaulting to 'Hermes'."""
|
||||
d, status = get("/api/settings")
|
||||
assert status == 200
|
||||
assert "bot_name" in d
|
||||
assert d["bot_name"] == "Hermes"
|
||||
|
||||
|
||||
# ── Round-trip ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_settings_set_bot_name():
|
||||
"""POST /api/settings with bot_name should persist and round-trip."""
|
||||
try:
|
||||
d, status = post("/api/settings", {"bot_name": "TestBot"})
|
||||
assert status == 200
|
||||
assert d.get("bot_name") == "TestBot"
|
||||
d2, _ = get("/api/settings")
|
||||
assert d2.get("bot_name") == "TestBot"
|
||||
finally:
|
||||
post("/api/settings", {"bot_name": "Hermes"})
|
||||
|
||||
|
||||
def test_settings_bot_name_special_chars():
|
||||
"""bot_name with safe special characters should persist correctly."""
|
||||
try:
|
||||
d, status = post("/api/settings", {"bot_name": "My Assistant 2.0"})
|
||||
assert status == 200
|
||||
d2, _ = get("/api/settings")
|
||||
assert d2.get("bot_name") == "My Assistant 2.0"
|
||||
finally:
|
||||
post("/api/settings", {"bot_name": "Hermes"})
|
||||
|
||||
|
||||
# ── Server-side sanitization ──────────────────────────────────────────────
|
||||
|
||||
def test_settings_empty_bot_name_defaults_to_hermes():
|
||||
"""Posting an empty bot_name should default to 'Hermes' server-side."""
|
||||
try:
|
||||
d, status = post("/api/settings", {"bot_name": ""})
|
||||
assert status == 200
|
||||
assert d.get("bot_name") == "Hermes"
|
||||
d2, _ = get("/api/settings")
|
||||
assert d2.get("bot_name") == "Hermes"
|
||||
finally:
|
||||
post("/api/settings", {"bot_name": "Hermes"})
|
||||
|
||||
|
||||
def test_settings_whitespace_bot_name_defaults_to_hermes():
|
||||
"""Posting a whitespace-only bot_name should default to 'Hermes'."""
|
||||
try:
|
||||
d, status = post("/api/settings", {"bot_name": " "})
|
||||
assert status == 200
|
||||
assert d.get("bot_name") == "Hermes"
|
||||
finally:
|
||||
post("/api/settings", {"bot_name": "Hermes"})
|
||||
|
||||
|
||||
# ── Login page rendering ──────────────────────────────────────────────────
|
||||
|
||||
def test_login_page_shows_default_bot_name():
|
||||
"""GET /login should contain 'Hermes' in title and h1 when default."""
|
||||
html, status = get_raw("/login")
|
||||
assert status == 200
|
||||
assert "<title>Hermes" in html
|
||||
assert "<h1>Hermes</h1>" in html
|
||||
|
||||
|
||||
def test_login_page_shows_custom_bot_name():
|
||||
"""GET /login should reflect the configured bot_name."""
|
||||
try:
|
||||
post("/api/settings", {"bot_name": "Aria"})
|
||||
html, status = get_raw("/login")
|
||||
assert status == 200
|
||||
assert "<title>Aria" in html
|
||||
assert "<h1>Aria</h1>" in html
|
||||
finally:
|
||||
post("/api/settings", {"bot_name": "Hermes"})
|
||||
|
||||
|
||||
def test_login_page_empty_name_does_not_crash():
|
||||
"""Login page must not 500 even if somehow bot_name is empty in settings."""
|
||||
# Force an empty value by patching settings file directly — skipped here
|
||||
# because the server-side guard in POST /api/settings prevents storing empty.
|
||||
# Instead, verify that /login returns 200 reliably.
|
||||
html, status = get_raw("/login")
|
||||
assert status == 200
|
||||
assert "Sign in" in html
|
||||
|
||||
|
||||
def test_login_page_xss_escaped():
|
||||
"""bot_name with HTML special chars should be escaped in the login page."""
|
||||
try:
|
||||
post("/api/settings", {"bot_name": "<script>alert(1)</script>"})
|
||||
html, status = get_raw("/login")
|
||||
assert status == 200
|
||||
# Raw tag must not appear unescaped
|
||||
assert "<script>alert(1)</script>" not in html
|
||||
# Escaped form should appear
|
||||
assert "<script>" in html
|
||||
finally:
|
||||
post("/api/settings", {"bot_name": "Hermes"})
|
||||
227
tests/test_sprint28.py
Normal file
227
tests/test_sprint28.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user