diff --git a/api/routes.py b/api/routes.py index 9f9d276..4e5d5ac 100644 --- a/api/routes.py +++ b/api/routes.py @@ -9,9 +9,39 @@ import sys import threading import time import uuid +import importlib +from contextlib import contextmanager from pathlib import Path from urllib.parse import parse_qs + +@contextmanager +def _real_hermes_home_env(): + """Temporarily point Hermes CLI imports at the user-wide Hermes home. + + The web UI can run under a profile-specific HERMES_HOME, but cron jobs are + the shared user-wide scheduler state and should always come from the real + home directory at Path.home() / '.hermes'. + """ + old = os.environ.get('HERMES_HOME') + os.environ['HERMES_HOME'] = str(Path.home() / '.hermes') + try: + yield + finally: + if old is None: + os.environ.pop('HERMES_HOME', None) + else: + os.environ['HERMES_HOME'] = old + + +def _cron_module(): + """Import cron.jobs from the real Hermes agent checkout, even if already cached.""" + with _real_hermes_home_env(): + agent_dir = Path.home() / '.hermes' / 'hermes-agent' + sys.path.insert(0, str(agent_dir)) + mod = importlib.import_module('cron.jobs') + return importlib.reload(mod) + from api.config import ( STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE, DEFAULT_MODEL, SESSIONS, SESSIONS_MAX, LOCK, STREAMS, STREAMS_LOCK, CANCEL_FLAGS, @@ -129,13 +159,15 @@ def handle_get(handler, parsed): return _handle_approval_pending(handler, parsed) if parsed.path == '/api/approval/inject_test': + # Loopback-only: used by automated tests; blocked from any remote client + if handler.client_address[0] != '127.0.0.1': + return j(handler, {'error': 'not found'}, status=404) return _handle_approval_inject(handler, parsed) # ── Cron API (GET) ── if parsed.path == '/api/crons': - sys.path.insert(0, str(Path(__file__).parent.parent)) - from cron.jobs import list_jobs - return j(handler, {'jobs': list_jobs(include_disabled=True)}) + jobs = _cron_module().list_jobs(include_disabled=True) + return j(handler, {'jobs': jobs}) if parsed.path == '/api/crons/output': return _handle_cron_output(handler, parsed) @@ -335,10 +367,9 @@ def handle_post(handler, parsed): def _serve_static(handler, parsed): static_root = (Path(__file__).parent.parent / 'static').resolve() - # Strip the leading '/static/' prefix and resolve the full path + # Strip the leading '/static/' prefix, then resolve and sandbox rel = parsed.path[len('/static/'):] static_file = (static_root / rel).resolve() - # Sandbox check: resolved path must stay inside static_root try: static_file.relative_to(static_root) except ValueError: @@ -494,6 +525,7 @@ def _handle_approval_pending(handler, parsed): def _handle_approval_inject(handler, parsed): + """Inject a fake pending approval -- loopback-only, used by automated tests.""" qs = parse_qs(parsed.query) sid = qs.get('session_id', [''])[0] key = qs.get('pattern_key', ['test_pattern'])[0] @@ -508,7 +540,7 @@ def _handle_approval_inject(handler, parsed): def _handle_cron_output(handler, parsed): - from cron.jobs import OUTPUT_DIR as CRON_OUT + CRON_OUT = _cron_module().OUTPUT_DIR qs = parse_qs(parsed.query) job_id = qs.get('job_id', [''])[0] limit = int(qs.get('limit', ['5'])[0]) @@ -532,9 +564,7 @@ def _handle_cron_recent(handler, parsed): qs = parse_qs(parsed.query) since = float(qs.get('since', ['0'])[0]) try: - sys.path.insert(0, str(Path(__file__).parent.parent)) - from cron.jobs import list_jobs - jobs = list_jobs(include_disabled=True) + jobs = _cron_module().list_jobs(include_disabled=True) completions = [] for job in jobs: last_run = job.get('last_run_at') @@ -637,10 +667,7 @@ def _handle_chat_sync(handler, body): try: from run_agent import AIAgent with CHAT_LOCK: - from api.config import resolve_model_provider - _model, _provider, _base_url = resolve_model_provider(s.model) - agent = AIAgent(model=_model, provider=_provider, base_url=_base_url, - platform='cli', quiet_mode=True, + agent = AIAgent(model=s.model, platform='cli', quiet_mode=True, enabled_toolsets=CLI_TOOLSETS, session_id=s.session_id) workspace_ctx = f"[Workspace: {s.workspace}]\n" workspace_system_msg = ( @@ -682,8 +709,7 @@ def _handle_cron_create(handler, body): try: require(body, 'prompt', 'schedule') except ValueError as e: return bad(handler, str(e)) try: - from cron.jobs import create_job - job = create_job( + job = _cron_module().create_job( prompt=body['prompt'], schedule=body['schedule'], name=body.get('name') or None, deliver=body.get('deliver') or 'local', skills=body.get('skills') or [], model=body.get('model') or None, @@ -696,9 +722,8 @@ def _handle_cron_create(handler, body): def _handle_cron_update(handler, body): try: require(body, 'job_id') except ValueError as e: return bad(handler, str(e)) - from cron.jobs import update_job updates = {k: v for k, v in body.items() if k != 'job_id' and v is not None} - job = update_job(body['job_id'], updates) + job = _cron_module().update_job(body['job_id'], updates) if not job: return bad(handler, 'Job not found', 404) return j(handler, {'ok': True, 'job': job}) @@ -706,8 +731,7 @@ def _handle_cron_update(handler, body): def _handle_cron_delete(handler, body): try: require(body, 'job_id') except ValueError as e: return bad(handler, str(e)) - from cron.jobs import remove_job - ok = remove_job(body['job_id']) + ok = _cron_module().remove_job(body['job_id']) if not ok: return bad(handler, 'Job not found', 404) return j(handler, {'ok': True, 'job_id': body['job_id']}) @@ -715,19 +739,17 @@ def _handle_cron_delete(handler, body): def _handle_cron_run(handler, body): job_id = body.get('job_id', '') if not job_id: return bad(handler, 'job_id required') - from cron.jobs import get_job - from cron.scheduler import run_job - job = get_job(job_id) + cron_mod = _cron_module() + job = cron_mod.get_job(job_id) if not job: return bad(handler, 'Job not found', 404) - threading.Thread(target=run_job, args=(job,), daemon=True).start() + threading.Thread(target=cron_mod.run_job if hasattr(cron_mod, 'run_job') else __import__('cron.scheduler', fromlist=['run_job']).run_job, args=(job,), daemon=True).start() return j(handler, {'ok': True, 'job_id': job_id, 'status': 'triggered'}) def _handle_cron_pause(handler, body): job_id = body.get('job_id', '') if not job_id: return bad(handler, 'job_id required') - from cron.jobs import pause_job - result = pause_job(job_id, reason=body.get('reason')) + result = _cron_module().pause_job(job_id, reason=body.get('reason')) if result: return j(handler, {'ok': True, 'job': result}) return bad(handler, 'Job not found', 404) @@ -735,8 +757,7 @@ def _handle_cron_pause(handler, body): def _handle_cron_resume(handler, body): job_id = body.get('job_id', '') if not job_id: return bad(handler, 'job_id required') - from cron.jobs import resume_job - result = resume_job(job_id) + result = _cron_module().resume_job(job_id) if result: return j(handler, {'ok': True, 'job': result}) return bad(handler, 'Job not found', 404) @@ -879,6 +900,8 @@ def _handle_skill_save(handler, body): if not skill_name or '/' in skill_name or '..' in skill_name: return bad(handler, 'Invalid skill name') category = body.get('category', '').strip() + if category and ('/' in category or '..' in category): + return bad(handler, 'Invalid category') from tools.skills_tool import SKILLS_DIR if category: skill_dir = SKILLS_DIR / category / skill_name diff --git a/static/index.html b/static/index.html index ecc6653..544796f 100644 --- a/static/index.html +++ b/static/index.html @@ -6,14 +6,14 @@ Hermes - - - + + +
-
Hermes
Start a new conversation
+
Hermes
Start a new conversation
GPT-5.4 Mini
diff --git a/static/ui.js b/static/ui.js index 679c89e..a766875 100644 --- a/static/ui.js +++ b/static/ui.js @@ -11,7 +11,7 @@ async function populateModelDropdown(){ const sel=$('modelSelect'); if(!sel) return; try{ - const data=await fetch(new URL('/api/models',location.origin).href,{credentials:'include'}).then(r=>r.json()); + const data=await fetch('/api/models').then(r=>r.json()); if(!data.groups||!data.groups.length) return; // keep HTML defaults // Clear existing options sel.innerHTML=''; @@ -88,12 +88,12 @@ function renderMd(raw){ }); s=s.replace(/```([\w+-]*)\n?([\s\S]*?)```/g,(_,lang,code)=>{const h=lang?`
${esc(lang)}
`:'';return `${h}
${esc(code.replace(/\n$/,''))}
`;}); s=s.replace(/`([^`\n]+)`/g,(_,c)=>`${esc(c)}`); - s=s.replace(/\*\*\*(.+?)\*\*\*/g,'$1'); - s=s.replace(/\*\*(.+?)\*\*/g,'$1'); - s=s.replace(/\*([^*\n]+)\*/g,'$1'); - s=s.replace(/^### (.+)$/gm,'

$1

').replace(/^## (.+)$/gm,'

$1

').replace(/^# (.+)$/gm,'

$1

'); + s=s.replace(/\*\*\*(.+?)\*\*\*/g,(_,t)=>`${esc(t)}`); + s=s.replace(/\*\*(.+?)\*\*/g,(_,t)=>`${esc(t)}`); + s=s.replace(/\*([^*\n]+)\*/g,(_,t)=>`${esc(t)}`); + s=s.replace(/^### (.+)$/gm,(_,t)=>`

${esc(t)}

`).replace(/^## (.+)$/gm,(_,t)=>`

${esc(t)}

`).replace(/^# (.+)$/gm,(_,t)=>`

${esc(t)}

`); s=s.replace(/^---+$/gm,'
'); - s=s.replace(/^> (.+)$/gm,'
$1
'); + s=s.replace(/^> (.+)$/gm,(_,t)=>`
${esc(t)}
`); // B8: improved list handling supporting up to 2 levels of indentation s=s.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,block=>{ const lines=block.trimEnd().split('\n'); @@ -101,8 +101,8 @@ function renderMd(raw){ for(const l of lines){ const indent=/^ {2,}/.test(l); const text=l.replace(/^ {0,4}[-*+] /,''); - if(indent) html+=`
  • ${text}
  • `; - else html+=`
  • ${text}
  • `; + if(indent) html+=`
  • ${esc(text)}
  • `; + else html+=`
  • ${esc(text)}
  • `; } return html+''; }); @@ -111,19 +111,19 @@ function renderMd(raw){ let html='
      '; for(const l of lines){ const text=l.replace(/^ {0,4}\d+\. /,''); - html+=`
    1. ${text}
    2. `; + html+=`
    3. ${esc(text)}
    4. `; } return html+'
    '; }); - s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,'$1'); + s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,label,url)=>`${esc(label)}`); // Tables: | col | col | header row followed by | --- | --- | separator then data rows s=s.replace(/((?:^\|.+\|\n?)+)/gm,block=>{ const rows=block.trim().split('\n').filter(r=>r.trim()); if(rows.length<2)return block; const isSep=r=>/^\|[\s|:-]+\|$/.test(r.trim()); if(!isSep(rows[1]))return block; - const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`${c.trim()}`).join(''); - const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`${c.trim()}`).join(''); + const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`${esc(c.trim())}`).join(''); + const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`${esc(c.trim())}`).join(''); const header=`${parseHeader(rows[0])}`; const body=rows.slice(2).map(r=>`${parseRow(r)}`).join(''); return `${header}${body}
    `; @@ -568,7 +568,9 @@ function renderMermaidBlocks(){ if(!_mermaidLoading){ _mermaidLoading=true; const script=document.createElement('script'); - script.src='https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js'; + script.src='https://cdn.jsdelivr.net/npm/mermaid@10.9.3/dist/mermaid.min.js'; + script.integrity='sha384-R63zfMfSwJF4xCR11wXii+QUsbiBIdiDzDbtxia72oGWfkT7WHJfmD/I/eeHPJyT'; + script.crossOrigin='anonymous'; script.onload=()=>{ if(typeof mermaid!=='undefined'){ mermaid.initialize({startOnLoad:false,theme:'dark',themeVariables:{ @@ -745,7 +747,7 @@ async function uploadPendingFiles(){ const f=S.pendingFiles[i];const fd=new FormData(); fd.append('session_id',S.session.session_id);fd.append('file',f,f.name); try{ - const res=await fetch(new URL('/api/upload',location.origin).href,{method:'POST',credentials:'include',body:fd}); + const res=await fetch('/api/upload',{method:'POST',body:fd}); if(!res.ok){const err=await res.text();throw new Error(err);} const data=await res.json(); if(data.error)throw new Error(data.error);