#!/usr/bin/env python3 """identity.py — resolver định danh per-user cho CEO AI OS (chạy trên Hermes). Hermes bơm các biến HERMES_SESSION_* vào env của tiến trình terminal (xem tools/environments/local.py::_make_run_env). Script này đọc thẳng env → user_id ỔN ĐỊNH (vd tg_123456789), KHÔNG để model tự đặt id theo tên. Chỉ dùng thư viện chuẩn (không cần pyyaml). Registry = users/_index.json (gitignored). Lệnh: python3 lib/identity.py whoami -> JSON {user_id, exists, chat_id, name, dir} python3 lib/identity.py ensure [--name=.. --role=..] -> tạo users// từ _template nếu chưa có + cập nhật registry python3 lib/identity.py set k=v ... -> cập nhật field trong registry (vd org_role=ceo) python3 lib/identity.py path -> in đường dẫn TUYỆT ĐỐI đã chặn path-escape python3 lib/identity.py selftest -> kiểm tra guardrail """ import os import re import sys import json import shutil import datetime PLATFORM_PREFIX = {"telegram": "tg", "slack": "sl", "discord": "dc", "whatsapp": "wa"} def _bundle_root(): # file tại /lib/identity.py return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) USERS = os.path.join(_bundle_root(), "users") TEMPLATE = os.path.join(USERS, "_template") INDEX = os.path.join(USERS, "_index.json") def _sess(name, default=""): return os.environ.get(name, default) or default def resolve_user(): """user_id ổn định từ HERMES_SESSION_* (None nếu không có id).""" platform = _sess("HERMES_SESSION_PLATFORM", "telegram").lower() uid = _sess("HERMES_SESSION_USER_ID") or _sess("HERMES_SESSION_CHAT_ID") if not uid: return None prefix = PLATFORM_PREFIX.get(platform) or (re.sub(r"[^a-z0-9]", "", platform)[:2] or "xx") safe = re.sub(r"[^A-Za-z0-9_-]", "", str(uid)) if not safe: return None return "%s_%s" % (prefix, safe) def user_path(user_id, *parts): """Đường dẫn trong users//, CHẶN cứng path-escape (../ ra ngoài).""" if not user_id or "/" in user_id or user_id in ("", ".", ".."): raise ValueError("user_id không hợp lệ: %r" % user_id) base = os.path.realpath(os.path.join(USERS, user_id)) target = os.path.realpath(os.path.join(base, *parts)) if parts else base if target != base and not target.startswith(base + os.sep): raise ValueError("path escape bị chặn: %r" % (parts,)) return target def ensure_user_dir(user_id): d = user_path(user_id) created = False if not os.path.isdir(d): if os.path.isdir(TEMPLATE): shutil.copytree(TEMPLATE, d) else: os.makedirs(d, exist_ok=True) created = True return d, created def load_index(): try: with open(INDEX, encoding="utf-8") as f: return json.load(f) except Exception: return {"version": 1, "users": {}} def save_index(idx): os.makedirs(USERS, exist_ok=True) tmp = INDEX + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(idx, f, ensure_ascii=False, indent=2) os.replace(tmp, INDEX) def upsert_index(user_id, **fields): idx = load_index() users = idx.setdefault("users", {}) u = users.setdefault(user_id, {}) now = datetime.date.today().isoformat() u["user_id"] = user_id u.setdefault("created_at", now) u["last_seen"] = now u.setdefault("platform", _sess("HERMES_SESSION_PLATFORM", "telegram")) chat_id = _sess("HERMES_SESSION_CHAT_ID") if chat_id: u["chat_id"] = chat_id # đích gửi nudge (DM) name = _sess("HERMES_SESSION_USER_NAME") if name and not u.get("display_name"): u["display_name"] = name for k, v in fields.items(): if v not in (None, ""): u[k] = v save_index(idx) return u def _render_profile(user_id, f): """Dựng profile.md (hồ sơ CHUNG) từ các field thu thập ở onboarding.""" today = datetime.date.today().isoformat() g = lambda k: (f.get(k) or "").strip() return ( "# Hồ sơ user — %s\n" "> Hồ sơ CHUNG (mọi persona đọc). Tạo/cập nhật bởi skill onboarding.\n\n" "- Xưng hô: %s\n" "- Vai trò (org_role): %s\n" "- Doanh nghiệp (ngành, quy mô): %s\n" "- 3 mục tiêu với AI: %s\n" "- Việc ngốn thời gian nhất: %s\n" "- Phòng ban / cơ cấu: %s\n" "- Cập nhật lúc: %s\n" ) % (user_id, g("name") or g("display_name"), g("org_role"), g("company"), g("goals"), g("time_sink"), g("departments"), today) def selftest(): ok = True try: user_path("tg_1", "../tg_2/x") print("FAIL: path escape KHÔNG bị chặn"); ok = False except ValueError: print("PASS: path escape bị chặn") p = user_path("tg_1", "ai_mentor", "progress.md") print("PASS: path thường ->", p) os.environ["HERMES_SESSION_PLATFORM"] = "telegram" os.environ["HERMES_SESSION_USER_ID"] = "999" r = resolve_user() print("PASS: resolve ->", r) if r == "tg_999" else print("FAIL: resolve ->", r) ok = ok and (r == "tg_999") print("SELFTEST_OK" if ok else "SELFTEST_FAIL") return 0 if ok else 1 def main(): args = sys.argv[1:] cmd = args[0] if args else "whoami" if cmd == "selftest": sys.exit(selftest()) user_id = resolve_user() if not user_id: print(json.dumps({"ok": False, "error": "thiếu HERMES_SESSION_USER_ID/CHAT_ID trong env"}, ensure_ascii=False)) sys.exit(2) if cmd == "whoami": d = user_path(user_id) idx = load_index().get("users", {}).get(user_id, {}) onboarded = bool(idx.get("display_name") and idx.get("org_role")) print(json.dumps({ "ok": True, "user_id": user_id, "dir": d, "exists": os.path.isdir(d), "onboarded": onboarded, "chat_id": _sess("HERMES_SESSION_CHAT_ID"), "name": _sess("HERMES_SESSION_USER_NAME"), }, ensure_ascii=False)) elif cmd == "ensure": d, created = ensure_user_dir(user_id) fields = {} for a in args[1:]: if a.startswith("--name="): fields["display_name"] = a.split("=", 1)[1] elif a.startswith("--role="): fields["org_role"] = a.split("=", 1)[1] u = upsert_index(user_id, **fields) print(json.dumps({"ok": True, "user_id": user_id, "dir": d, "created": created, "index": u}, ensure_ascii=False)) elif cmd == "set": fields = dict(a.split("=", 1) for a in args[1:] if "=" in a) u = upsert_index(user_id, **fields) print(json.dumps({"ok": True, "user_id": user_id, "index": u}, ensure_ascii=False)) elif cmd == "profile-set": # MERGE field vào registry rồi render profile.md từ bản gộp (gọi nhiều lần không xoá field cũ). # Field: --name= --org_role= --company= --goals= --time_sink= --departments= ensure_user_dir(user_id) raw = {} for a in args[1:]: if a.startswith("--") and "=" in a: k, v = a[2:].split("=", 1) raw[k] = v mapped = {} if raw.get("name") or raw.get("display_name"): mapped["display_name"] = raw.get("name") or raw.get("display_name") for k in ("org_role", "company", "goals", "time_sink", "departments"): if k in raw: mapped[k] = raw[k] u = upsert_index(user_id, **mapped) # merge vào registry (không ghi đè field rỗng) prof = user_path(user_id, "profile.md") with open(prof, "w", encoding="utf-8") as fh: fh.write(_render_profile(user_id, u)) # render từ bản GỘP print(json.dumps({"ok": True, "user_id": user_id, "profile": prof, "onboarded": bool(u.get("display_name") and u.get("org_role")), "index": u}, ensure_ascii=False)) elif cmd == "path": rel = args[1] if len(args) > 1 else "." try: print(user_path(user_id, rel)) except ValueError as e: print("ERROR: %s" % e, file=sys.stderr) sys.exit(3) else: print("unknown cmd: %s" % cmd, file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()