split: handler mixins for oauth/identity/project/automation/fullauto (94 methods) on integration base 02835f6

- CloudControlPlaneHandler mixes in OAuthMixin, IdentityMemoryMixin,
  ProjectMixin, AutomationMixin, FullAutoMixin; shared helpers move to
  cloud/handler_support.py with server.py re-exports (old import paths
  keep working, incl. _validate_env_branch for tests).
- Method bodies moved verbatim; routers, base plumbing, task/lifecycle,
  webhooks stay in server.py. No >100-line method body split.
- test_handler_split_conformance.py (6 tests) pins resolution, layer
  bans and method accounting (238 total, 144 + 94, no duplicates).
- test_p1_security_server.py: monkeypatch targets follow the defining
  module (handler_project MAX_PROJECTS_PER_USER, handler_identity
  relay helpers); assertions unchanged.

(cherry picked from commit 92165a2b097126470e4827327d96d592a253adf1)
This commit is contained in:
host committed 2026-11-03 02:49:26 +00:00
1 parent c900a0f78d
commit 935b530f84
9 files changed
+3439 -3119

No files matched your search

+708
View File
@@ -0,0 +1,708 @@
"""Handler mixin:IdentityMemoryMixin。
由 CloudControlPlaneHandler 拆出,方法体逐字搬移;
self.* 调用(含其它 mixin 的方法与留守的 _send_json / _db 等)
在混入后的同一类上解析,行为不变。本模块不 import cloud.server。
"""
from cloud.auto_memory import AutoMemoryStore
from cloud.cost import CostStore
from cloud.identity import IdentityStore
from cloud.kb import KBStore
from cloud.memory import MemoryStore
from cloud.relay import RelayError as _RelayError
from cloud.sub2api import Sub2apiError as _Sub2apiError
from cloud.auto_memory import process_due_tasks as _auto_process_due
from cloud.sub2api import gateway_name as _gateway_name
from cloud.sub2api import gateway_origin as _gateway_origin
from cloud.relay import get_relay_models as _get_relay_models
from cloud.sub2api import list_gateway_models as _list_sub2api_models
from cloud.sub2api import validate_or_rotate_gateway_key as _validate_sub2api_key
import os
import sqlite3
from cloud.handler_support import (
_auth_store as _auth_store,
_task_to_dict as _task_to_dict,
)
class IdentityMemoryMixin:
"""IdentityMemoryMixin 领域方法(见模块 docstring)。"""
def _memory_create(self, user_id, project_id):
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
goal = body.get("goal")
conclusion = body.get("conclusion")
if not goal or not isinstance(goal, str):
self._send_json(400, {"error": "goal required"})
return
if not conclusion or not isinstance(conclusion, str):
self._send_json(400, {"error": "conclusion required"})
return
store = MemoryStore(self.server.db_path)
try:
mid = store.add_summary(
project_id,
body.get("thread_id") or "",
goal,
conclusion,
files_changed=body.get("files_changed") or [],
open_issues=body.get("open_issues") or [],
)
finally:
try:
store.conn.close()
except Exception:
pass
with _auth_store(self.server.db_path) as auth:
auth.audit(user_id, project_id, "create_memory", str(mid))
self._send_json(201, {"id": mid, "project_id": project_id})
def _memory_list(self, project_id, query):
try:
top_k = int((query.get("top_k") or ["5"])[0])
except (TypeError, ValueError):
self._send_json(400, {"error": "top_k must be an integer"})
return
store = MemoryStore(self.server.db_path)
try:
summaries = store.list_summaries(project_id)
context = store.build_prompt_context(project_id, top_k=top_k)
finally:
try:
store.conn.close()
except Exception:
pass
self._send_json(200, {
"project_id": project_id,
"summaries": summaries[:top_k],
"prompt_context": context,
})
def _memory_supersede(self, user_id, project_id, mid_raw):
try:
mid = int(mid_raw)
except (TypeError, ValueError):
self._send_json(400, {"error": "id must be an integer"})
return
conn = self._db()
try:
row = conn.execute(
"SELECT project_id FROM summaries WHERE id = ?", (mid,)
).fetchone()
finally:
conn.close()
if row is None or row["project_id"] != project_id:
self._send_json(404, {"error": "summary not found"})
return
store = MemoryStore(self.server.db_path)
try:
store.deprecate(mid)
finally:
try:
store.conn.close()
except Exception:
pass
with _auth_store(self.server.db_path) as auth:
auth.audit(user_id, project_id, "supersede_memory", str(mid))
self._send_json(200, {"id": mid, "status": "deprecated"})
# -- System-level identity / memory (cloud only) ------------------
# These handlers never touch host.py, CODEX_HOME or any thread:
# they only read/write the control-plane database. The context
# endpoint returns text for an explicit caller; it does not
# inject anything into a task or a thread on its own.
def _identity_store(self):
return IdentityStore(self.server.db_path)
def _identity_close(self, store):
try:
store.conn.close()
except Exception:
pass
def _identity_profile_get(self, user_id):
store = self._identity_store()
try:
self._send_json(200, store.get_profile(user_id))
finally:
self._identity_close(store)
def _identity_profile_upsert(self, user_id):
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
store = self._identity_store()
try:
try:
profile = store.upsert_profile(
user_id,
display_name=body.get("display_name"),
timezone=body.get("timezone"),
preferences=body.get("preferences"))
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
finally:
self._identity_close(store)
with _auth_store(self.server.db_path) as auth:
auth.audit(user_id, "", "profile_updated", user_id)
self._send_json(200, profile)
def _identity_user_memories_list(self, user_id):
store = self._identity_store()
try:
rows = store.list_user_memories(user_id)
finally:
self._identity_close(store)
self._send_json(200, {"user_id": user_id, "memories": rows})
def _identity_user_memory_create(self, user_id):
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
store = self._identity_store()
try:
try:
mem_id = store.add_memory(
user_id, "user", body.get("kind"),
body.get("content"),
source_thread_id=body.get("source_thread_id"))
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
finally:
self._identity_close(store)
with _auth_store(self.server.db_path) as auth:
auth.audit(user_id, "", "identity_memory_created", mem_id)
self._send_json(201, {"id": mem_id, "scope": "user"})
def _identity_user_memory_supersede(self, user_id, mem_id):
body, err = self._read_json()
if err:
body = {}
store = self._identity_store()
try:
row = store.get_memory(mem_id)
if row is None or row["scope"] != "user" \
or row["owner_user_id"] != user_id:
self._send_json(404, {"error": "memory not found"})
return
try:
_old, new_id = store.supersede_memory(
mem_id, user_id,
replacement_content=body.get("replacement_content"),
replacement_kind=body.get("replacement_kind"))
except (KeyError, PermissionError):
self._send_json(404, {"error": "memory not found"})
return
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
finally:
self._identity_close(store)
with _auth_store(self.server.db_path) as auth:
auth.audit(user_id, "", "identity_memory_superseded", mem_id)
self._send_json(200, {"id": mem_id, "status": "superseded",
"replacement_id": new_id})
def _identity_user_memory_delete(self, user_id, mem_id):
store = self._identity_store()
try:
row = store.get_memory(mem_id)
if row is None or row["scope"] != "user" \
or row["owner_user_id"] != user_id:
self._send_json(404, {"error": "memory not found"})
return
try:
store.delete_memory(mem_id, user_id)
except (KeyError, PermissionError):
self._send_json(404, {"error": "memory not found"})
return
finally:
self._identity_close(store)
with _auth_store(self.server.db_path) as auth:
auth.audit(user_id, "", "identity_memory_deleted", mem_id)
self._send_json(200, {"deleted": True, "id": mem_id})
def _identity_context(self, user_id, query):
project_id = None
if query:
vals = query.get("project_id")
if vals:
project_id = vals[0]
if project_id:
err = self._check_project(user_id, project_id, "read")
if err:
self._send_json(err[0], {"error": err[1]})
return
store = self._identity_store()
try:
profile = store.get_profile(user_id)
user_rows = store.list_user_memories(user_id)
proj_rows = store.list_project_memories(project_id) \
if project_id else []
context = store.build_identity_context(user_id, project_id)
finally:
self._identity_close(store)
summaries_context = ""
if project_id:
mem_store = MemoryStore(self.server.db_path)
try:
summaries_context = mem_store.build_prompt_context(
project_id)
finally:
try:
mem_store.conn.close()
except Exception:
pass
self._send_json(200, {
"user_id": user_id,
"project_id": project_id,
"profile": profile,
"user_memories": user_rows,
"project_memories": proj_rows,
"identity_context": context,
# Kept as a separate field on purpose: the existing
# project summaries are NOT merged into identity_context
# and neither field is injected into any task/thread here.
"project_summaries_context": summaries_context,
})
# -- 自动记忆(官方 memories 对齐:设置 + 提取触发) ------------
# 设置与提取日志只归属本人;任务级开关走项目 create_task 门。
def _auto_memory_store(self):
return AutoMemoryStore(self.server.db_path)
def _auto_memory_close(self, store):
try:
store.close()
except Exception:
pass
def _auto_memory_settings_get(self, user_id):
store = self._auto_memory_store()
try:
self._send_json(200, store.get_settings(user_id))
finally:
self._auto_memory_close(store)
def _auto_memory_settings_upsert(self, user_id):
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
if not isinstance(body, dict):
self._send_json(400, {"error": "body must be an object"})
return
store = self._auto_memory_store()
try:
try:
settings = store.upsert_settings(user_id, **body)
except (ValueError, TypeError) as exc:
self._send_json(400, {"error": str(exc)})
return
finally:
self._auto_memory_close(store)
with _auth_store(self.server.db_path) as auth:
auth.audit(user_id, "", "auto_memory_settings_updated", "")
self._send_json(200, settings)
def _auto_memory_log(self, user_id, query):
try:
limit = int((query.get("limit") or ["50"])[0])
except (TypeError, ValueError):
self._send_json(400, {"error": "limit must be an integer"})
return
store = self._auto_memory_store()
try:
rows = store.list_log(user_id, limit=limit)
finally:
self._auto_memory_close(store)
self._send_json(200, {"user_id": user_id, "log": rows})
def _auto_memory_run(self, user_id):
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
force = False
if isinstance(body, dict) and body.get("force") is not None:
if body.get("force") not in (True, False, 0, 1):
self._send_json(400, {"error": "force must be a boolean"})
return
force = bool(body.get("force"))
results = _auto_process_due(
self.server.db_path, force=force, owner_id=user_id)
for item in results:
if item.get("status") == "extracted":
# 提取日志已在 process_due_tasks 里落库,这里只补
# 一条审计(不查任务归属:run 只扫本人的任务)。
task_id = item.get("task_id") or ""
with _auth_store(self.server.db_path) as auth:
auth.audit(user_id, "", "auto_memory_extracted",
task_id)
self._send_json(200, {"user_id": user_id, "results": results})
def _task_auto_memory_flags(self, user_id, task_id):
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
if not isinstance(body, dict):
self._send_json(400, {"error": "body must be an object"})
return
task = self._fetch_task(task_id)
if task is None:
self._send_json(404, {"error": "task not found"})
return
err = self._check_project(
user_id, task["project_id"], "create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
store = self._auto_memory_store()
try:
try:
flags = store.set_task_flags(
task_id,
use_memories=body.get("use_memories"),
generate_memories=body.get("generate_memories"))
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
finally:
self._auto_memory_close(store)
with _auth_store(self.server.db_path) as auth:
auth.audit(user_id, task["project_id"],
"task_auto_memory_flags", task_id)
self._send_json(200, {"task_id": task_id, "flags": flags})
def _project_identity_memory_create(self, user_id, project_id):
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
store = self._identity_store()
try:
try:
mem_id = store.add_memory(
user_id, "project", body.get("kind"),
body.get("content"), project_id=project_id,
source_thread_id=body.get("source_thread_id"))
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
finally:
self._identity_close(store)
with _auth_store(self.server.db_path) as auth:
auth.audit(user_id, project_id,
"identity_memory_created", mem_id)
self._send_json(201, {"id": mem_id, "scope": "project",
"project_id": project_id})
def _project_identity_memories_list(self, project_id, query):
try:
top_k = int((query.get("top_k") or ["20"])[0])
except (TypeError, ValueError):
self._send_json(400, {"error": "top_k must be an integer"})
return
store = self._identity_store()
try:
rows = store.list_project_memories(project_id)
finally:
self._identity_close(store)
self._send_json(200, {"project_id": project_id,
"memories": rows[:top_k]})
def _project_identity_memory_supersede(self, user_id, project_id,
mem_id):
body, err = self._read_json()
if err:
body = {}
store = self._identity_store()
try:
row = store.get_memory(mem_id)
if row is None or row["scope"] != "project" \
or row["project_id"] != project_id:
self._send_json(404, {"error": "memory not found"})
return
try:
_old, new_id = store.supersede_memory(
mem_id, user_id,
replacement_content=body.get("replacement_content"),
replacement_kind=body.get("replacement_kind"))
except (KeyError, PermissionError, ValueError) as exc:
if isinstance(exc, ValueError):
self._send_json(400, {"error": str(exc)})
else:
self._send_json(404, {"error": "memory not found"})
return
finally:
self._identity_close(store)
with _auth_store(self.server.db_path) as auth:
auth.audit(user_id, project_id,
"identity_memory_superseded", mem_id)
self._send_json(200, {"id": mem_id, "status": "superseded",
"replacement_id": new_id})
def _project_identity_memory_delete(self, user_id, project_id, mem_id):
store = self._identity_store()
try:
row = store.get_memory(mem_id)
if row is None or row["scope"] != "project" \
or row["project_id"] != project_id:
self._send_json(404, {"error": "memory not found"})
return
store.delete_memory(mem_id, user_id)
finally:
self._identity_close(store)
with _auth_store(self.server.db_path) as auth:
auth.audit(user_id, project_id,
"identity_memory_deleted", mem_id)
self._send_json(200, {"deleted": True, "id": mem_id})
def _kb_index(self, user_id, project_id):
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
path = body.get("path")
content = body.get("content")
if not path or not isinstance(path, str):
self._send_json(400, {"error": "path required"})
return
if content is None or not isinstance(content, str):
self._send_json(400, {"error": "content required"})
return
store = KBStore(self.server.db_path)
try:
doc_id = store.index_file(
project_id, path, content,
kind=body.get("kind") or "file")
except ValueError as e:
self._send_json(400, {"error": str(e)})
return
finally:
try:
store.conn.close()
except Exception:
pass
with _auth_store(self.server.db_path) as auth:
auth.audit(user_id, project_id, "kb_index", path)
self._send_json(201, {"id": doc_id, "project_id": project_id,
"path": path})
def _kb_search(self, project_id, query):
q = (query.get("q") or [""])[0]
if not q:
self._send_json(400, {"error": "q required"})
return
try:
top_k = int((query.get("top_k") or ["5"])[0])
except (TypeError, ValueError):
self._send_json(400, {"error": "top_k must be an integer"})
return
store = KBStore(self.server.db_path)
try:
results = store.search(project_id, q, top_k=top_k)
finally:
try:
store.conn.close()
except Exception:
pass
self._send_json(200, {"project_id": project_id, "query": q,
"results": results})
def _project_cost(self, project_id, query):
store = CostStore(self.server.db_path)
try:
# 双列:美元只计有价行,未定价模型单独列在
# unpriced_models,billing_note 标注代理价、非账单。
overall = store.dual_column_report_by_project(project_id)
conn = store._conn
conn.row_factory = sqlite3.Row
days = (query.get("days") or [None])[0]
day = (query.get("day") or [None])[0]
daily = []
if day:
row = conn.execute(
"SELECT day, COALESCE(SUM(total_tokens),0) AS t,"
" COALESCE(SUM(usd_micros),0) AS u, COUNT(*) AS c,"
" COALESCE(SUM(fallback_count),0) AS f,"
" COALESCE(SUM(cached_input_tokens),0) AS cached,"
" COALESCE(SUM(CASE WHEN source = 'actual'"
" AND usd_micros IS NULL THEN total_tokens"
" ELSE 0 END),0) AS unpriced"
" FROM cost_records WHERE project_id = ? AND day = ?"
" GROUP BY day",
(project_id, day)).fetchone()
if row:
daily = [{"day": row["day"],
"total_tokens": int(row["t"]),
"usd_micros": int(row["u"]),
"turns": int(row["c"]),
"fallback_count": int(row["f"]),
"cached_input_tokens": int(row["cached"]),
"unpriced_actual_tokens": int(row["unpriced"])}]
elif days is not None:
try:
n = int(days)
except (TypeError, ValueError):
self._send_json(400, {"error": "days must be an integer"})
return
from datetime import date, timedelta
cutoff = (date.today() - timedelta(days=max(n - 1, 0))
).isoformat()
rows = conn.execute(
"SELECT day, COALESCE(SUM(total_tokens),0) AS t,"
" COALESCE(SUM(usd_micros),0) AS u, COUNT(*) AS c,"
" COALESCE(SUM(fallback_count),0) AS f,"
" COALESCE(SUM(cached_input_tokens),0) AS cached,"
" COALESCE(SUM(CASE WHEN source = 'actual'"
" AND usd_micros IS NULL THEN total_tokens"
" ELSE 0 END),0) AS unpriced"
" FROM cost_records WHERE project_id = ? AND day >= ?"
" GROUP BY day ORDER BY day",
(project_id, cutoff)).fetchall()
daily = [{"day": r["day"],
"total_tokens": int(r["t"]),
"usd_micros": int(r["u"]),
"turns": int(r["c"]),
"fallback_count": int(r["f"]),
"cached_input_tokens": int(r["cached"]),
"unpriced_actual_tokens": int(r["unpriced"])}
for r in rows]
finally:
try:
store._conn.close()
except Exception:
pass
self._send_json(200, {"project_id": project_id, "overall": overall,
"daily": daily})
# -- 任务成本与模型总览 ----------------------------------------------
def _task_cost(self, user_id, task_id):
row = self._fetch_task(task_id)
if row is None:
self._send_json(404, {"error": "task not found"})
return
task = _task_to_dict(row)
with _auth_store(self.server.db_path) as auth:
if not auth.check_permission(user_id, task["project_id"], "read"):
self._send_json(403, {"error": "forbidden"})
return
store = CostStore(self.server.db_path)
try:
# 双列:与项目成本同款,旧键全保留,只加双列键。
report = store.dual_column_report_by_task(task_id)
finally:
try:
store._conn.close()
except Exception:
pass
self._send_json(200, {"task_id": task_id,
"project_id": task["project_id"], **report})
def _relay_models(self, user_id):
# 中转可用模型列表:服务端凭环境变量里的 relay key 现取
# 现回,只把模型 id 交给调用者。key 绝不进响应、日志、
# 缓存(见 cloud/relay.py)。单测可经 server.relay_fetcher
# 注入假取数函数;中转挂了或未配 key 时 502,不编造列表。
# 网关为 sub2api 时:模型目录因用户而异(key 绑定的组
# 决定)。P1 收紧:首次调模型目录即做真正的懒开通/
# 健康检查,不再只回 provisioning: pending——无行则开通,
# 旧 key 经 /v1/models 实测已死(401/403,含余额为 0 的
# 403)则轮换重开;网关本身不可用时回 502、保留旧行。
# key 明文绝不进响应、日志、缓存。
if _gateway_name() == "sub2api":
conn = self._db()
try:
try:
status, api_key, _sub, _kid = _validate_sub2api_key(
conn, user_id)
except _Sub2apiError as exc:
self._send_json(502, {"error": str(exc)})
return
except Exception as exc:
self._send_json(502, {"error": str(exc)})
return
finally:
conn.close()
try:
models = _list_sub2api_models(_gateway_origin(), api_key)
except Exception as exc:
self._send_json(502, {"error": str(exc)})
return
self._send_json(200, {"models": models, "gateway": "sub2api",
"cached": False, "updated_at": None,
"provisioning": status})
return
fetcher = getattr(self.server, "relay_fetcher", None)
try:
models, cached, updated_at = _get_relay_models(fetcher=fetcher)
except _RelayError as exc:
self._send_json(502, {"error": str(exc)})
return
self._send_json(200, {
"models": models, "cached": cached, "updated_at": updated_at,
"gateway": "xcmapi"})
def _models_overview(self, user_id):
# 优先级列表来自环境变量 MODELS_PRIORITY(逗号分隔),未设时
# 用默认列表;这只是控制面的默认配置,不是 provider 的承诺。
raw = os.environ.get("MODELS_PRIORITY", "")
priority = [m.strip() for m in raw.split(",") if m.strip()] or [
"gpt-6.1-sol", "gpt-6-sol", "gpt-5.6-sol"]
# 只统计调用者是成员的项目,避免跨项目泄露成本。
conn = self._db()
try:
member_ids = [r["project_id"] for r in conn.execute(
"SELECT project_id FROM project_members WHERE user_id = ?",
(user_id,)).fetchall()]
finally:
conn.close()
if not member_ids:
self._send_json(200, {"priority_list": priority,
"model_stats": [], "fallback_events": []})
return
placeholders = ",".join("?" for _ in member_ids)
store = CostStore(self.server.db_path)
try:
conn = store._conn
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT model, COUNT(*) AS turns,"
" COALESCE(SUM(total_tokens),0) AS tokens,"
" COALESCE(SUM(fallback_count),0) AS fallbacks"
" FROM cost_records WHERE project_id IN (%s)"
" GROUP BY model ORDER BY model" % placeholders,
member_ids).fetchall()
fb_rows = conn.execute(
"SELECT task_id, project_id, model, fallback_count"
" FROM cost_records WHERE project_id IN (%s)"
" AND fallback_count > 0"
" ORDER BY created_at DESC LIMIT 50" % placeholders,
member_ids).fetchall()
finally:
try:
store._conn.close()
except Exception:
pass
self._send_json(200, {
"priority_list": priority,
"model_stats": [
{"model": r["model"], "turns": int(r["turns"]),
"total_tokens": int(r["tokens"]),
"fallback_count": int(r["fallbacks"])} for r in rows],
"fallback_events": [
{"task_id": r["task_id"], "project_id": r["project_id"],
"model": r["model"],
"fallback_count": int(r["fallback_count"])}
for r in fb_rows],
})