Phase 8: TypeScript migration, i18n rewrite, Activity Tree, Projects API, Heartbeats

This commit is contained in:
Rose
2026-04-29 11:50:00 +02:00
parent c705fad626
commit 255914c9f1
43 changed files with 17948 additions and 6899 deletions

View File

@@ -61,6 +61,20 @@ from api import heartbeats as _heartbeats
import re as _re
_re_path = _re.compile(r"^(?P<path>/[^?]*)")
def _extract_origin_from_headers(handler) -> str | None:
"""Extract the best origin from request headers (Origin or Referer)."""
origin = handler.headers.get('Origin', '')
if origin:
return origin
referer = handler.headers.get('Referer', '')
if referer:
# Extract origin from Referer header
m = _re.match(r'^(https?://[^/]+)', referer)
if m:
return m.group(1)
return None
def _normalize_host_port(value: str) -> tuple[str, str | None]:
"""Split a host or host:port string into (hostname, port|None).
Handles IPv6 bracket notation, e.g. [::1]:8080."""
@@ -128,12 +142,15 @@ def _check_csrf(handler) -> bool:
origin = handler.headers.get("Origin", "")
referer = handler.headers.get("Referer", "")
host = handler.headers.get("Host", "")
x_fwd_host = handler.headers.get("X-Forwarded-Host", "")
if not origin and not referer:
return True # non-browser clients (curl, agent) have no Origin
target = origin or referer
# Extract host:port from origin/referer
m = _re.match(r"^https?://([^/]+)", target)
if not m:
import sys
print(f"[CSRF DEBUG] no host match in target={target!r}", flush=True, file=sys.stderr)
return False
origin_host = m.group(1)
origin_scheme = m.group(0).split('://')[0].lower() # 'http' or 'https'
@@ -142,6 +159,9 @@ def _check_csrf(handler) -> bool:
origin_value = m.group(0).rstrip('/').lower()
if origin_value in _allowed_public_origins():
return True
# Allow dev-mission.sabo.synology.me for development
if origin_name == "dev-mission.sabo.synology.me":
return True
# Allow same-origin: check Host, X-Forwarded-Host (reverse proxy), and
# X-Real-Host against the origin. Reverse proxies (Caddy, nginx) set
# X-Forwarded-Host to the client's original Host header.
@@ -158,6 +178,9 @@ def _check_csrf(handler) -> bool:
allowed_name, allowed_port = _normalize_host_port(allowed)
if origin_name == allowed_name and _ports_match(origin_scheme, origin_port, allowed_port):
return True
# DEBUG: log what we rejected
import sys
print(f"[CSRF DEBUG] REJECTED origin={origin!r} referer={referer!r} host={host!r} x_fwd_host={x_fwd_host!r} origin_name={origin_name}", flush=True, file=sys.stderr)
return False
@@ -569,7 +592,13 @@ def handle_get(handler, parsed) -> bool:
return j(handler, {"sessions": safe_merged, "cli_count": len(deduped_cli)})
if parsed.path == "/api/projects":
return j(handler, {"projects": load_projects()})
# Transform from old {project_id, name} format to new {id, name} format
raw = load_projects()
projects = [{"id": p.get("project_id") or p.get("id"),
"name": p.get("name", ""),
"color": p.get("color", "#6366f1"),
"tasks": []} for p in raw]
return j(handler, {"projects": projects})
# ── Projects Tab Tasks (NEW) ──────────────────────────────────────────────
from api import projects as _projects
@@ -961,6 +990,10 @@ def handle_get(handler, parsed) -> bool:
return j(handler, {"error": str(e)}, status=500)
# GET /api/heartbeats — list all + status
if parsed.path == "/api/heartbeats/stats":
return j(handler, _heartbeats.handle_get(parsed.path))
if parsed.path == "/api/heartbeats/config":
return j(handler, _heartbeats.handle_get(parsed.path))
if parsed.path == "/api/heartbeats" or parsed.path.startswith("/api/heartbeats/"):
result = _heartbeats.handle_get(parsed.path)
if result is not None:
@@ -1031,6 +1064,10 @@ def handle_post(handler, parsed) -> bool:
except ValueError as e:
return bad(handler, str(e))
s = new_session(workspace=workspace, model=body.get("model"))
# Save agent to session if provided
if body.get("agent"):
s.agent = body.get("agent")
s.save()
return j(handler, {"session": s.compact() | {"messages": s.messages}})
if parsed.path == "/api/sessions/cleanup":
@@ -1052,6 +1089,21 @@ def handle_post(handler, parsed) -> bool:
s.save()
return j(handler, {"session": s.compact()})
if parsed.path == "/api/session/reorder":
# Drag & drop reorder — update the session's updated_at to reposition it
try:
require(body, "session_id", "weight")
except ValueError as e:
return bad(handler, str(e))
try:
s = get_session(body["session_id"])
except KeyError:
return bad(handler, "Session not found", 404)
# weight is a float timestamp used as sort key; set it to target + small delta
s.updated_at = float(body["weight"])
s.save()
return j(handler, {"ok": True})
if parsed.path == "/api/personality/set":
try:
require(body, "session_id")
@@ -1478,6 +1530,10 @@ def handle_post(handler, parsed) -> bool:
if "bot_name" in body:
body["bot_name"] = (str(body["bot_name"]) or "").strip() or "Hermes"
if "user_emoji" in body:
body["user_emoji"] = (str(body["user_emoji"]) or "").strip()[:8] or "🙂"
if "user_name" in body:
body["user_name"] = (str(body["user_name"]) or "").strip()[:32] or "You"
auth_enabled_before = is_auth_enabled()
current_cookie = parse_cookie(handler)
@@ -1658,7 +1714,8 @@ def handle_post(handler, parsed) -> bool:
# Unassign all sessions that belonged to this project
if SESSION_INDEX_FILE.exists():
try:
index = json.loads(SESSION_INDEX_FILE.read_text(encoding="utf-8"))
with SESSION_INDEX_FILE.open(encoding="utf-8") as f:
index = json.loads(f.read())
for entry in index:
if entry.get("project_id") == body["project_id"]:
try:
@@ -1737,6 +1794,12 @@ def handle_post(handler, parsed) -> bool:
return True
# POST /api/heartbeats — create heartbeat
if parsed.path == "/api/heartbeats/config":
result = _heartbeats.handle_post(parsed.path, body)
if result is not None:
status = 200
if isinstance(result, tuple): result, status = result
return j(handler, result, status=status)
if parsed.path == "/api/heartbeats" or parsed.path.startswith("/api/heartbeats/"):
result = _heartbeats.handle_post(parsed.path, body)
if result is not None:
@@ -1780,6 +1843,10 @@ def handle_put(handler, parsed) -> bool:
agent_id = parsed.path.split("/")[-2]
return j(handler, _agents.update_agent_memory(agent_id, body.get("content", "")))
# PUT /api/skills/toggle
if parsed.path == "/api/skills/toggle":
return _handle_skill_toggle(handler, body)
return False # 404
@@ -1953,6 +2020,8 @@ def _handle_sse_stream(handler, parsed):
return j(handler, {"error": "stream not found"}, status=404)
handler.send_response(200)
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
# NOTE: Content-Encoding:gzip removed — requires gzip writer wrapper on wfile
# Without actual gzip compression the header would cause browser decode errors
handler.send_header("Cache-Control", "no-cache")
handler.send_header("X-Accel-Buffering", "no")
handler.send_header("Connection", "keep-alive")
@@ -1966,7 +2035,7 @@ def _handle_sse_stream(handler, parsed):
handler.wfile.flush()
continue
_sse(handler, event, data)
if event in ("stream_end", "error", "cancel"):
if event in ("stream_end", "error", "cancel", "apperror"):
break
except (BrokenPipeError, ConnectionResetError):
pass
@@ -3232,6 +3301,12 @@ def _handle_skill_save(handler, body):
if category and ("/" in category or ".." in category):
return bad(handler, "Invalid category")
from tools.skills_tool import SKILLS_DIR
import shutil
# Find and remove ALL existing instances of this skill (handles category-change updates)
existing = list(SKILLS_DIR.rglob(f"{skill_name}/SKILL.md"))
for old_file in existing:
shutil.rmtree(str(old_file.parent))
if category:
skill_dir = SKILLS_DIR / category / skill_name
@@ -3264,6 +3339,34 @@ def _handle_skill_delete(handler, body):
return j(handler, {"ok": True, "name": body["name"]})
def _handle_skill_toggle(handler, body):
"""Enable or disable a skill by name."""
name = body.get("name")
if not name:
return bad(handler, "Missing field: name")
enabled = body.get("enabled")
if enabled is None:
return bad(handler, "Missing field: enabled")
import sys as _sys
from pathlib import Path as _P
_agent_path = (_P(__file__).parent.parent / "hermes-agent").resolve()
if str(_agent_path) not in _sys.path:
_sys.path.insert(0, str(_agent_path))
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
from api.config import load_config
config = load_config()
disabled = get_disabled_skills(config)
if enabled:
disabled.discard(name)
else:
disabled.add(name)
save_disabled_skills(config, disabled)
return j(handler, {"ok": True, "name": name, "enabled": enabled})
def _handle_memory_write(handler, body):
try:
require(body, "section", "content")