Files
codex-app-server/cloud/identity.py
T
host 6e9d6dc5e8 云端身份与记忆:用户画像+分级记忆,不动 thread
- 新增 cloud/identity.py:user_profiles(显示名/时区/偏好,偏好禁密钥键)
  与 identity_memories(user 级仅本人可见、project 级项目成员可见,
  kind fact/preference/decision/convention,supersede 链+物理删除)。
- server 新增 /users/me/profile、/users/me/memories、/projects/{id}/
  identity-memories、/users/me/identity-context,项目记忆先过项目
  read/create_task 门;上下文只显式取用,不自动注入任务/thread。
- PG schema 与迁移清单同步新增两张 TEXT 主键表,序列清单不动;
  server 补 do_PUT 入口(/users/me/profile 首个 PUT 路由)。
- 新测试 11 个;三套件 cloud 937 OK(4 跳过)、host 89 OK、deploy 29 OK。
- 未动:host.py、worker、CODEX_HOME、旧 summaries 表、控制台 UI;
  未推、未建 PR、未部署。
2026-10-28 03:43:02 +08:00

302 lines
12 KiB
Python

"""System-level identity and memory, cloud layer only.
Design (deliberately NOT touching Codex threads):
- Identity lives in the control-plane database, not in CODEX_HOME.
A Codex thread is never rewritten by this module: no thread_id is
created, resumed, forked or archived here, and no prompt is sent to
any worker. ``build_identity_context`` only *formats* text for a
caller that explicitly asks for it; whether that text ever reaches
a new turn is the caller's decision, recorded separately.
- Two scopes, no cross-user leakage by construction:
- ``user``: owned by one user, visible only to that user, across
projects (project_id must be empty).
- ``project``: owned by a user but visible to project members;
the store does NOT know project membership, so the server must
check project ``read``/``create_task`` permission before calling
the project-scope methods. Project-scope ownership checks in
this module only guard the ``user`` scope; project membership
is enforced one layer up.
- Supersede is a chain, not an overwrite: the old row becomes
``superseded`` and points at its replacement (``superseded_by``),
so a correction stays auditable. ``delete`` is physical removal,
reserved for user-initiated forgetting, mirroring MemoryStore.
- Profiles never hold secrets: preference keys containing password /
secret / token / api_key / credential are rejected, and values are
capped, so a profile cannot become a second credential store.
Tables use TEXT primary keys on purpose: no IDENTITY sequence to
keep in sync in cloud/migrations.py SEQUENCE_TABLES (see the
test_p2_migrations alignment test). The same CREATE TABLE statements
must appear verbatim in cloud/db.py POSTGRES_SCHEMA.
"""
import json
import time
import uuid
from cloud.db import connect as _db_connect
_SCOPES = ("user", "project")
_KINDS = ("fact", "preference", "decision", "convention")
_SECRET_KEY_WORDS = ("password", "secret", "token", "api_key", "apikey",
"private_key", "credential")
_MAX_CONTENT = 4000
_MAX_PREFERENCES_BYTES = 8192
def _clean_str(value, max_len, field):
if not isinstance(value, str):
raise ValueError("%s must be a string" % field)
value = value.strip()
if not value:
raise ValueError("%s required" % field)
if len(value) > max_len:
raise ValueError("%s too long" % field)
if any(ord(c) < 32 and c not in ("\n", "\t") for c in value):
raise ValueError("%s contains control characters" % field)
return value
def _validate_preferences(preferences):
if preferences is None:
return None
if not isinstance(preferences, dict):
raise ValueError("preferences must be an object")
for key in preferences:
if not isinstance(key, str):
raise ValueError("preferences keys must be strings")
lowered = key.lower()
if any(word in lowered for word in _SECRET_KEY_WORDS):
raise ValueError(
"preferences must not hold secrets: %s" % key)
try:
encoded = json.dumps(preferences, ensure_ascii=False)
except (TypeError, ValueError):
raise ValueError("preferences must be JSON-serializable")
if len(encoded.encode("utf-8")) > _MAX_PREFERENCES_BYTES:
raise ValueError("preferences too large")
return preferences
class IdentityStore:
"""User profiles + scoped identity memories (SQLite or Postgres).
Instantiation creates the tables if missing (idempotent), the
same pattern as MemoryStore / KBStore.
"""
def __init__(self, db_path):
self.db_path = db_path
self.conn = _db_connect(db_path)
self.conn.execute(
"CREATE TABLE IF NOT EXISTS user_profiles("
"user_id TEXT PRIMARY KEY, "
"display_name TEXT, "
"timezone TEXT, "
"preferences TEXT, "
"updated_at REAL)"
)
self.conn.execute(
"CREATE TABLE IF NOT EXISTS identity_memories("
"id TEXT PRIMARY KEY, "
"owner_user_id TEXT, "
"scope TEXT, "
"project_id TEXT, "
"kind TEXT, "
"content TEXT, "
"source_thread_id TEXT, "
"status TEXT DEFAULT 'active', "
"superseded_by TEXT, "
"created_at REAL, "
"updated_at REAL)"
)
self.conn.commit()
# -- profiles ------------------------------------------------------
def get_profile(self, user_id):
row = self.conn.execute(
"SELECT * FROM user_profiles WHERE user_id = ?",
(user_id,)).fetchone()
if row is None:
return {"user_id": user_id, "display_name": "",
"timezone": "", "preferences": {},
"updated_at": None}
d = dict(row)
try:
d["preferences"] = json.loads(d["preferences"]) \
if d["preferences"] else {}
except (TypeError, ValueError):
d["preferences"] = {}
return d
def upsert_profile(self, user_id, display_name=None, timezone=None,
preferences=None):
"""Partial update: only fields that are not None change."""
if display_name is not None:
display_name = _clean_str(display_name, 100, "display_name")
if timezone is not None:
timezone = _clean_str(timezone, 64, "timezone")
preferences = _validate_preferences(preferences)
now = time.time()
existing = self.conn.execute(
"SELECT * FROM user_profiles WHERE user_id = ?",
(user_id,)).fetchone()
if existing is None:
self.conn.execute(
"INSERT INTO user_profiles(user_id, display_name, "
"timezone, preferences, updated_at) VALUES(?, ?, ?, ?, ?)",
(user_id, display_name or "",
timezone or "",
json.dumps(preferences) if preferences is not None
else "{}",
now))
else:
row = dict(existing)
try:
old_prefs = json.loads(row["preferences"]) \
if row["preferences"] else {}
except (TypeError, ValueError):
old_prefs = {}
self.conn.execute(
"UPDATE user_profiles SET display_name = ?, "
"timezone = ?, preferences = ?, updated_at = ? "
"WHERE user_id = ?",
(display_name if display_name is not None
else (row["display_name"] or ""),
timezone if timezone is not None
else (row["timezone"] or ""),
json.dumps(preferences) if preferences is not None
else json.dumps(old_prefs),
now, user_id))
self.conn.commit()
return self.get_profile(user_id)
# -- memories -------------------------------------------------------
def add_memory(self, owner_user_id, scope, kind, content,
project_id=None, source_thread_id=None):
if scope not in _SCOPES:
raise ValueError("scope must be 'user' or 'project'")
if kind not in _KINDS:
raise ValueError("kind must be one of %s" % (", ".join(_KINDS),))
content = _clean_str(content, _MAX_CONTENT, "content")
if scope == "user":
if project_id:
raise ValueError(
"user-scope memory must not carry a project_id")
project_id = ""
else:
if not project_id or not isinstance(project_id, str):
raise ValueError(
"project-scope memory requires a project_id")
project_id = project_id.strip()
if source_thread_id is not None:
source_thread_id = _clean_str(
source_thread_id, 128, "source_thread_id")
mem_id = uuid.uuid4().hex
now = time.time()
self.conn.execute(
"INSERT INTO identity_memories(id, owner_user_id, scope, "
"project_id, kind, content, source_thread_id, status, "
"superseded_by, created_at, updated_at) "
"VALUES(?, ?, ?, ?, ?, ?, ?, 'active', '', ?, ?)",
(mem_id, owner_user_id, scope, project_id, kind, content,
source_thread_id or "", now, now))
self.conn.commit()
return mem_id
def get_memory(self, mem_id):
row = self.conn.execute(
"SELECT * FROM identity_memories WHERE id = ?",
(mem_id,)).fetchone()
return dict(row) if row is not None else None
def list_user_memories(self, user_id, include_superseded=False):
sql = ("SELECT * FROM identity_memories "
"WHERE owner_user_id = ? AND scope = 'user'")
if not include_superseded:
sql += " AND status = 'active'"
sql += " ORDER BY created_at DESC, id DESC"
rows = self.conn.execute(sql, (user_id,)).fetchall()
return [dict(r) for r in rows]
def list_project_memories(self, project_id, include_superseded=False):
"""Caller must already hold project ``read`` permission."""
sql = ("SELECT * FROM identity_memories "
"WHERE scope = 'project' AND project_id = ?")
if not include_superseded:
sql += " AND status = 'active'"
sql += " ORDER BY created_at DESC, id DESC"
rows = self.conn.execute(sql, (project_id,)).fetchall()
return [dict(r) for r in rows]
def supersede_memory(self, mem_id, actor_user_id,
replacement_content=None, replacement_kind=None):
"""Mark old row superseded; optionally create its replacement.
User-scope rows may only be superseded by their owner
(PermissionError otherwise). Project-scope membership is
enforced by the server before calling this method.
Returns (old_id, new_id_or_None).
"""
row = self.get_memory(mem_id)
if row is None:
raise KeyError("memory not found")
if row["scope"] == "user" and row["owner_user_id"] != actor_user_id:
raise PermissionError("not the owner of this memory")
new_id = None
if replacement_content is not None:
kind = replacement_kind or row["kind"]
new_id = self.add_memory(
row["owner_user_id"], row["scope"], kind,
replacement_content,
project_id=(row["project_id"] or None)
if row["scope"] == "project" else None,
source_thread_id=(row["source_thread_id"] or None))
self.conn.execute(
"UPDATE identity_memories SET status = 'superseded', "
"superseded_by = ?, updated_at = ? WHERE id = ?",
(new_id or "", time.time(), mem_id))
self.conn.commit()
return mem_id, new_id
def delete_memory(self, mem_id, actor_user_id):
"""Physical removal (user-initiated forgetting only)."""
row = self.get_memory(mem_id)
if row is None:
raise KeyError("memory not found")
if row["scope"] == "user" and row["owner_user_id"] != actor_user_id:
raise PermissionError("not the owner of this memory")
self.conn.execute(
"DELETE FROM identity_memories WHERE id = ?", (mem_id,))
self.conn.commit()
return True
def build_identity_context(self, user_id, project_id=None,
top_k_user=5, top_k_project=5):
"""Format active memories for an explicit caller.
Returns "" when there is nothing active. Never returns the
full history: at most top_k per scope. Project memories are
included only when project_id is given; the caller must have
checked project ``read`` permission first.
"""
sections = []
user_rows = self.list_user_memories(user_id)[:top_k_user]
if user_rows:
lines = ["[用户记忆]"]
for r in user_rows:
lines.append("- [%s] %s" % (r["kind"], r["content"]))
sections.append("\n".join(lines))
if project_id:
proj_rows = self.list_project_memories(
project_id)[:top_k_project]
if proj_rows:
lines = ["[项目记忆]"]
for r in proj_rows:
lines.append("- [%s] %s" % (r["kind"], r["content"]))
sections.append("\n".join(lines))
return "\n".join(sections)