#!/usr/bin/env python3 """server.py — Live AI OS Dashboard: read-only API + static server (stdlib only). Binds 127.0.0.1 ONLY (public via Caddy HTTPS hoặc SSH tunnel). LUÔN enforce basicauth (kể cả khi đi qua SSH tunnel không có Caddy). Chỉ trả PROJECTION đã chắt lọc — không body, không PII, không chat_id, không secret — nên kể cả lọt auth cũng không rò gì nhạy cảm. Không có endpoint ghi. Env (systemd EnvironmentFile=/opt/ai-os/instance/dashboard.env): DASH_USER, DASH_PASS_HASH (sha256 hex của mật khẩu), DASH_PORT (mặc định 20129) """ import os import sys import json import hmac import base64 import hashlib import re import html as html_mod import html import sqlite3 import subprocess import urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer HERE = os.path.dirname(os.path.abspath(__file__)) # .../dashboard/api DASH = os.path.dirname(HERE) # .../dashboard PROD = os.path.dirname(DASH) # .../products/ceo (= bundle root runtime) STATIC = os.path.realpath(os.path.join(DASH, "static")) USERS_INDEX = os.path.join(PROD, "users", "_index.json") FILES_ROOT = os.path.realpath("/") # root filesystem cho route /files/ # Danh sách các đường dẫn bị cấm đọc qua /files/ (whitelist-negative) FILES_BLOCKED = [ "auth.json", ".env", "google_token.json", ".git", "__pycache__", "node_modules", ".key", ".pem", "credentials.json", "token.pickle", "dashboard/static/fonts/", # không cần thiết "state.db", # database files ] DASH_USER = os.environ.get("DASH_USER", "admin") DASH_PASS_HASH = os.environ.get("DASH_PASS_HASH", "") # sha256 hex; rỗng = chặn hết (an toàn) PORT = int(os.environ.get("DASH_PORT", "20129")) CTYPE = {".html": "text/html; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png"} # Các extension cho phép đọc raw RAW_EXT = {".md", ".txt", ".yaml", ".yml", ".json", ".csv", ".toml", ".ini", ".cfg", ".conf", ".log", ".xml", ".html", ".sh", ".py", ".js", ".css", ".env.example"} def _lib(script, *args, timeout=20): """Chạy 1 lib CLI của bundle từ cwd=PROD → parse JSON (read-only helpers).""" try: p = subprocess.run([sys.executable, os.path.join(PROD, "lib", script), *args], cwd=PROD, capture_output=True, text=True, encoding="utf-8", timeout=timeout) return json.loads(p.stdout) if p.stdout.strip() else {} except Exception as e: return {"ok": False, "error": str(e)} KANBAN_CANDIDATES = [os.environ.get("HERMES_KANBAN_DB"), "/root/.hermes/kanban/boards/nv-office/kanban.db", os.path.expanduser("~/.hermes/kanban.db"), "/root/.hermes/kanban.db"] KANBAN_COL = [("todo", "Cần làm", ("triage", "todo", "ready")), ("running", "Đang chạy", ("running",)), ("blocked", "Tắc", ("blocked",)), ("done", "Xong", ("done",))] def _kanban(group_by="status", sort="priority"): """Mirror CHỈ-ĐỌC bảng Kanban của Hermes (~/.hermes/kanban.db). Chỉ lấy field an toàn (title/assignee/status/priority) — không body/secret. Tạo & kéo thẻ làm qua Telegram /kanban.""" path = next((p for p in KANBAN_CANDIDATES if p and os.path.isfile(p)), None) if not path: return {"ok": True, "available": False, "columns": []} try: con = sqlite3.connect("file:%s?mode=ro" % path, uri=True, timeout=2) con.row_factory = sqlite3.Row if sort == "title_asc": order_clause = "ORDER BY title ASC" elif sort == "title_desc": order_clause = "ORDER BY title DESC" elif sort == "newest": order_clause = "ORDER BY created_at DESC" else: # priority DESC, created_at DESC order_clause = "ORDER BY priority DESC, created_at DESC" rows = con.execute("SELECT id,title,assignee,status,priority FROM tasks " "WHERE status != 'archived' " + order_clause).fetchall() con.close() except Exception as e: return {"ok": False, "available": False, "error": str(e), "columns": []} if group_by == "assignee": # Group by assignee assignees = sorted(list(set([r["assignee"] or "Unassigned" for r in rows]))) bucket = {a: [] for a in assignees} for r in rows: key = r["assignee"] or "Unassigned" bucket[key].append({"id": r["id"], "title": r["title"] or "(không tên)", "assignee": r["assignee"] or "", "status": r["status"], "priority": int(r["priority"] or 0)}) cols = [{"key": k, "title": k, "tasks": bucket[k]} for k in assignees] else: # Default status grouping bucket = {k: [] for k, _, _ in KANBAN_COL} for r in rows: key = next((k for k, _, sts in KANBAN_COL if r["status"] in sts), "todo") bucket[key].append({"id": r["id"], "title": r["title"] or "(không tên)", "assignee": r["assignee"] or "", "status": r["status"], "priority": int(r["priority"] or 0)}) cols = [{"key": k, "title": t, "tasks": bucket[k]} for k, t, _ in KANBAN_COL] return {"ok": True, "available": True, "total": len(rows), "columns": cols} def _team(): try: d = json.load(open(USERS_INDEX, encoding="utf-8")) members = [{"name": u.get("display_name") or uid, "role": u.get("org_role", ""), "last_seen": u.get("last_seen", "")} for uid, u in (d.get("users") or {}).items()] return {"ok": True, "members": members} except Exception: return {"ok": True, "members": []} def _api(path, query=""): if path == "/api/health": return {"ok": True} if path == "/api/mentors": return _lib("mentors.py", "list") if path == "/api/mentor": mid = urllib.parse.parse_qs(query).get("id", [""])[0] return _lib("mentors.py", "detail", mid) if path == "/api/usecases": return _lib("advisor_tools.py", "prioritize") if path == "/api/customer-health": return _lib("advisor_tools.py", "health") if path == "/api/kb-graph": return _lib("kb_graph.py", "graph") if path == "/api/kb-note": nid = urllib.parse.parse_qs(query).get("path", [""])[0] return _lib("kb_graph.py", "note", nid) if path == "/api/phases": return _lib("growth.py", "phases") if path == "/api/startup": return _lib("startup_kit.py", "status") if path == "/api/plan": return _lib("startup_kit.py", "plan") if path == "/api/profile": return _lib("startup_kit.py", "profile", "--no-md") if path == "/api/checklist": return _lib("cd_checklist.py", "status") if path == "/api/kb": return _lib("kb.py", "status") if path == "/api/skills": try: return json.load(open(os.path.join(PROD, "content", "skills_catalog.json"), encoding="utf-8")) except Exception: return {"ok": True, "groups": []} if path == "/api/guides": try: return json.load(open(os.path.join(PROD, "content", "startup_kit", "guides.json"), encoding="utf-8")) except Exception: return {} if path == "/api/process": try: return json.load(open(os.path.join(PROD, "content", "customer_development.json"), encoding="utf-8")) except Exception: return {"steps": []} if path == "/api/activity": return {"ok": True, "summary": _lib("activity.py", "summary"), "recent": _lib("activity.py", "recent", "--limit", "20").get("events", [])} if path == "/api/team": return _team() if path == "/api/task-detail": tid = urllib.parse.parse_qs(query).get("id", [""])[0] if not tid: return {"ok": False, "error": "missing id"} path_db = next((p for p in KANBAN_CANDIDATES if p and os.path.isfile(p)), None) if not path_db: return {"ok": False, "error": "db not found"} try: con = sqlite3.connect("file:%s?mode=ro" % path_db, uri=True, timeout=2) con.row_factory = sqlite3.Row # Lấy toàn bộ thông tin task row = con.execute("SELECT * FROM tasks WHERE id = ?", (tid,)).fetchone() if not row: con.close() return {"ok": False, "error": "not found"} task_data = dict(row) # Lấy toàn bộ comments của task này comments = con.execute("SELECT author, body, created_at FROM task_comments WHERE task_id = ? ORDER BY created_at ASC", (tid,)).fetchall() comment_list = [{"author": c["author"], "body": c["body"], "created_at": c["created_at"]} for c in comments] # Lấy toàn bộ run history của task này runs = con.execute("SELECT id, profile, status, started_at, ended_at, outcome, summary, metadata, error FROM task_runs WHERE task_id = ? ORDER BY started_at DESC", (tid,)).fetchall() run_list = [] for r in runs: run_list.append({ "id": r["id"], "profile": r["profile"], "status": r["status"], "started_at": r["started_at"], "ended_at": r["ended_at"], "outcome": r["outcome"], "summary": r["summary"], "metadata": r["metadata"], "error": r["error"] }) # Fallback logic: Nếu t.result bị trống, lấy summary của run completed mới nhất làm result if not task_data.get("result"): for r in run_list: if r["status"] == "completed" and r["summary"]: task_data["result"] = r["summary"] break elif r["status"] == "completed" and r["outcome"]: task_data["result"] = r["outcome"] break con.close() # Đọc log file của worker nếu có log_content = "" log_path = f"/root/.hermes/kanban/boards/nv-office/logs/{tid}.log" if os.path.isfile(log_path): try: with open(log_path, "r", encoding="utf-8", errors="replace") as lf: # Lấy 50KB cuối của file log để tránh quá tải lf.seek(0, 2) fsize = lf.tell() if fsize > 50000: lf.seek(fsize - 50000) log_content = lf.read() # Đảm bảo không bị đứt dòng đầu tiên sau seek if "\n" in log_content: log_content = log_content[log_content.index("\n")+1:] else: lf.seek(0) log_content = lf.read() except Exception as log_err: log_content = f"Lỗi đọc log: {str(log_err)}" else: log_content = "Không tìm thấy file log hoặc task chưa được chạy." return { "ok": True, "task": task_data, "comments": comment_list, "runs": run_list, "log": log_content } except Exception as e: return {"ok": False, "error": str(e)} if path == "/api/kanban": qp = urllib.parse.parse_qs(query) group_by = qp.get("group_by", ["status"])[0] sort = qp.get("sort", ["priority"])[0] return _kanban(group_by, sort) if path == "/api/kpi": return {"ok": True, "available": False, "reason": "Chưa có nguồn dữ liệu KPI — sẽ bật khi kích hoạt skill KPI."} return None def _md_to_html(md_text): """Minimal Markdown renderer for .md files, producing nice HTML.""" lines = md_text.split("\n") out = ['
'] in_code = False in_table = None # None=not in table, "header"=first row, True=in body for line in lines: if line.strip().startswith("```"): lang = line.strip()[3:].strip() if in_code: out.append("\n") in_code = False else: out.append('
'.format(html_mod.escape(lang)))
                in_code = True
            continue
        if in_code:
            out.append(html_mod.escape(line) + "\n")
            continue
        stripped = line.strip()
        if not stripped:
            if in_table is None:
                out.append('

') continue if stripped.startswith("# "): out.append("

{}

".format(html_mod.escape(stripped[2:]))) elif stripped.startswith("## "): out.append("

{}

".format(html_mod.escape(stripped[3:]))) elif stripped.startswith("### "): out.append("

{}

".format(html_mod.escape(stripped[4:]))) elif stripped in ("---", "***", "___"): out.append("
") elif stripped.startswith("|"): cells = [c.strip() for c in stripped.split("|") if c.strip()] if in_table is None: out.append("") in_table = "header" elif in_table == "header": if cells and all(c.startswith(":") or c.startswith("-") for c in cells): out.append("") in_table = True else: out.append("") in_table = True else: out.append("") for c in cells: if c.startswith(":") or c.startswith("-"): continue out.append("".format(html_mod.escape(c))) out.append("") elif in_table is not None and in_table is not True: # We were in a table header but hit a non-table row in_table = True out.append("") out.append("".format(html_mod.escape(stripped))) out.append("") elif stripped.startswith("- ") or stripped.startswith("* "): out.append("
  • {}
  • ".format(html_mod.escape(stripped[2:]))) elif re.match(r"^\d+[.)]\s", stripped): content = re.sub(r"^\d+[.)]\s", "", stripped) out.append("
  • {}
  • ".format(html_mod.escape(content))) else: rendered = html_mod.escape(line) rendered = re.sub(r"\*\*(.+?)\*\*", r"\1", rendered) rendered = re.sub(r"\*(.+?)\*", r"\1", rendered) rendered = re.sub(r"`(.+?)`", r"\1", rendered) rendered = re.sub(r"\[(.+?)\]\((.+?)\)", r'\1', rendered) out.append(rendered + "\n") if in_code: out.append("") if in_table is not None: out.append("
    {}
    {}
    ") out.append("
    ") return "".join(out) def _is_blocked(fpath): """Check if a file path matches the blocked list.""" for b in FILES_BLOCKED: if b in fpath: return True if b.startswith("*") and fpath.endswith(b[1:]): return True return False def _serve_file(handler, fpath, is_md=False): """Read a file from disk and serve it (HTML for .md, raw for others).""" try: with open(fpath, "rb") as f: data = f.read() except Exception: handler._send(404, {"error": "not found"}) return if is_md: md_html = _md_to_html(data.decode("utf-8", errors="replace")) html_page = """ {title} - CEO AI OS {body} """.format( title=html_mod.escape(os.path.basename(fpath)), dirname=html_mod.escape(os.path.dirname(fpath).replace(FILES_ROOT, "") or "/"), filename=html_mod.escape(os.path.basename(fpath)), body=md_html ) handler._send(200, html_page, "text/html; charset=utf-8") else: ext = os.path.splitext(fpath)[1].lower() ctype = CTYPE.get(ext, "text/plain; charset=utf-8") handler._send(200, data, ctype) class H(BaseHTTPRequestHandler): server_version = "aios-dash" def _auth_ok(self): if not DASH_PASS_HASH: return False h = self.headers.get("Authorization", "") if not h.startswith("Basic "): return False try: user, _, pw = base64.b64decode(h[6:]).decode("utf-8").partition(":") except Exception: return False pwh = hashlib.sha256(pw.encode("utf-8")).hexdigest() return hmac.compare_digest(user, DASH_USER) and hmac.compare_digest(pwh, DASH_PASS_HASH) def _send(self, code, body, ctype="application/json; charset=utf-8"): if isinstance(body, (dict, list)): body = json.dumps(body, ensure_ascii=False).encode("utf-8") elif isinstance(body, str): body = body.encode("utf-8") self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.send_header("X-Content-Type-Options", "nosniff") self.send_header("Cache-Control", "no-store") self.end_headers() if self.command != "HEAD": self.wfile.write(body) def do_GET(self): if not self._auth_ok(): self.send_response(401) self.send_header("WWW-Authenticate", 'Basic realm="AI OS Dashboard"') self.send_header("Content-Length", "0") self.end_headers() return path, _, query = self.path.partition("?") if path.startswith("/api/"): r = _api(path, query) return self._send(200 if r is not None else 404, r if r is not None else {"ok": False, "error": "not found"}) # Route /files/ — đọc file từ PROD if path.startswith("/files/"): rel = path[7:] # bỏ /files/ # resolve path, chặn path traversal target = os.path.realpath(os.path.join(FILES_ROOT, rel)) if not target.startswith(FILES_ROOT + os.sep) and target != FILES_ROOT: return self._send(403, {"error": "forbidden"}) if _is_blocked(target): return self._send(403, {"error": "blocked"}) if os.path.isdir(target): # List directory items = sorted(os.listdir(target)) lines = ["", "

    📁 {}

    ") return self._send(200, "".join(lines), "text/html; charset=utf-8") if not os.path.isfile(target): return self._send(404, {"error": "not found"}) ext = os.path.splitext(target)[1].lower() if ext == ".md": from html import escape _serve_file(self, target, is_md=True, html_esc=escape) elif ext in RAW_EXT: with open(target, "rb") as f: self._send(200, f.read(), CTYPE.get(ext, "text/plain; charset=utf-8")) else: # file nhị phân — serve raw (browser sẽ download hoặc hiển thị) ctype = CTYPE.get(ext, "application/octet-stream") with open(target, "rb") as f: self._send(200, f.read(), ctype) return # Static files (default) rel = "index.html" if path in ("/", "") else path.lstrip("/") target = os.path.realpath(os.path.join(STATIC, rel)) if target != STATIC and not target.startswith(STATIC + os.sep): return self._send(403, {"error": "forbidden"}) if not os.path.isfile(target): return self._send(404, {"error": "not found"}) with open(target, "rb") as f: data = f.read() self._send(200, data, CTYPE.get(os.path.splitext(target)[1], "application/octet-stream")) do_HEAD = do_GET def do_POST(self): if not self._auth_ok(): self.send_response(401) self.send_header("WWW-Authenticate", 'Basic realm="AI OS Dashboard"') self.send_header("Content-Length", "0") self.end_headers() return path = self.path.split("?", 1)[0] # Cho phép toggle checklist, unblock và complete kanban if path not in ("/api/checklist/toggle", "/api/kanban/unblock", "/api/kanban/complete"): return self._send(404, {"ok": False, "error": "not found"}) try: n = int(self.headers.get("Content-Length", "0") or "0") if n > 4096: return self._send(413, {"ok": False, "error": "too_large"}) body = json.loads(self.rfile.read(n).decode("utf-8") or "{}") except Exception: return self._send(400, {"ok": False, "error": "bad_json"}) if path == "/api/checklist/toggle": stage, step, path_arg = str(body.get("stage", "")), str(body.get("step", "")), body.get("path") if not stage or not step or path_arg is None: return self._send(400, {"ok": False, "error": "missing_fields"}) done = "1" if body.get("done") else "0" r = _lib("cd_checklist.py", "toggle", stage, step, str(path_arg), done, DASH_USER) return self._send(200 if r.get("ok") else 400, r) elif path in ("/api/kanban/unblock", "/api/kanban/complete"): tid = body.get("id") if not tid: return self._send(400, {"ok": False, "error": "missing_task_id"}) # Validate task id format to prevent shell injection if not re.match(r"^t_[a-f0-9]+$", tid): return self._send(400, {"ok": False, "error": "invalid_task_id"}) cmd = "unblock" if path == "/api/kanban/unblock" else "complete" try: # Run CLI command hermes kanban unblock/complete # Use absolute path to hermes binary res = subprocess.run( ["/usr/local/lib/hermes-agent/venv/bin/hermes", "kanban", cmd, tid], capture_output=True, text=True, timeout=15, env=os.environ ) if res.returncode == 0: return self._send(200, {"ok": True, "message": f"Task {tid} marked {cmd} successfully.", "stdout": res.stdout}) else: return self._send(500, {"ok": False, "error": res.stderr or "Lỗi CLI thực thi"}) except Exception as e: return self._send(500, {"ok": False, "error": str(e)}) def log_message(self, *a): pass def main(): print("aios-dashboard 127.0.0.1:%d user=%s auth=%s" % (PORT, DASH_USER, "on" if DASH_PASS_HASH else "OFF")) ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever() if __name__ == "__main__": main()