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:
1 parent
c900a0f78d
commit
935b530f84
9 files changed
+3439
-3119
No files matched your search
@@ -0,0 +1,537 @@
|
||||
"""Handler mixin:AutomationMixin。
|
||||
|
||||
由 CloudControlPlaneHandler 拆出,方法体逐字搬移;
|
||||
self.* 调用(含其它 mixin 的方法与留守的 _send_json / _db 等)
|
||||
在混入后的同一类上解析,行为不变。本模块不 import cloud.server。
|
||||
"""
|
||||
|
||||
from cloud.automations import cancel_automation as _auto_cancel
|
||||
from cloud.automations import create_automation as _auto_create
|
||||
from cloud.automations import ensure_automations_tables as _auto_ensure
|
||||
from cloud.automations import get_automation as _auto_get
|
||||
from cloud.automations import list_automations as _auto_list
|
||||
from cloud.automations import list_automation_runs as _auto_runs
|
||||
from cloud.automations import triage_inbox as _auto_triage
|
||||
from cloud.automations import update_automation as _auto_update
|
||||
from cloud.lifecycle import bind_automation as _life_bind
|
||||
from cloud.lifecycle import get_binding as _life_get_binding
|
||||
from cloud.lifecycle import unbind_automation as _life_unbind
|
||||
from cloud.marker_tools import parse_first_run_at as _parse_first_run_at
|
||||
from cloud.push import (
|
||||
create_subscription as _push_create,
|
||||
dispatch_event as _push_dispatch,
|
||||
)
|
||||
from cloud.todos import create_todo as _todo_create
|
||||
from cloud.todos import delete_todo as _todo_delete
|
||||
from cloud.todos import get_todo as _todo_get
|
||||
from cloud.todos import list_todos as _todo_list
|
||||
from cloud.todos import update_todo as _todo_update
|
||||
from cloud.handler_support import (
|
||||
_auth_store as _auth_store,
|
||||
_ensure_followups_table as _ensure_followups_table,
|
||||
_task_to_dict as _task_to_dict,
|
||||
)
|
||||
|
||||
|
||||
class AutomationMixin:
|
||||
"""AutomationMixin 领域方法(见模块 docstring)。"""
|
||||
|
||||
# -- 推送订阅与分发 ---------------------------------------------------
|
||||
def _push_create_endpoint(self, user_id, project_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
try:
|
||||
sub = _push_create(
|
||||
self.server.db_path, project_id, user_id,
|
||||
body.get("channel"), body.get("target"),
|
||||
body.get("preference") or "instant")
|
||||
except ValueError as e:
|
||||
self._send_json(400, {"error": str(e)})
|
||||
return
|
||||
# 审计只记订阅 id 与通道:target 全文不记(Slack webhook
|
||||
# URL 本身含 secret 路径,记进审计等于把 secret 落盘)。
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id, "push_subscription_created",
|
||||
"subscription_id=%s channel=%s"
|
||||
% (sub["id"], sub["channel"]))
|
||||
self._send_json(201, sub)
|
||||
|
||||
def _push_dispatch_endpoint(self, user_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
event_type = body.get("event_type")
|
||||
project_id = body.get("project_id")
|
||||
if not event_type or not project_id:
|
||||
self._send_json(400, {"error": "event_type and project_id required"})
|
||||
return
|
||||
# P0 安全批后:/admin/push/dispatch 是管理端点,只认
|
||||
# CODEX_GLOBAL_ADMINS——该口径已强于 P1 要求的 create_task,
|
||||
# 不再降级。被拒与成功都记审计;目标 SSRF 校验在
|
||||
# push.dispatch_event / 真实发送层做。
|
||||
if not self._is_global_admin(user_id):
|
||||
self._audit_admin(user_id, "admin_push_denied",
|
||||
"dispatch %s %s" % (event_type, project_id))
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
# 分发:sender=None 时用 push 的内置真实发送端
|
||||
# (webhook/slack 真 POST,webpush/apns/fcm 仍报缺凭证);
|
||||
# 显式注入的 sender 优先于内置端(见 push.dispatch_event)。
|
||||
result = _push_dispatch(
|
||||
self.server.db_path, event_type, project_id,
|
||||
body.get("payload") or {}, sender=None)
|
||||
self._audit_admin(user_id, "admin_push_dispatch",
|
||||
"dispatch %s %s" % (event_type, project_id))
|
||||
self._send_json(200, result)
|
||||
|
||||
# -- webhooks(签名认证,不走 X-API-Key)---------------------------
|
||||
# -- 自动化与分诊收件箱(P1 第 2 项)---------------------------
|
||||
# 权限口径与其它项目级写操作同款:创建/取消用 create_task
|
||||
# (不是 manage_project,因为自动化是项目成员的日常编排,
|
||||
# 不是改项目归属/仓库这类 owner 专属操作;评审/审批等写
|
||||
# 操作同样用 create_task);读用 read。状态变更记审计。
|
||||
# 失败口径:校验失败 400;模块/数据库层意外失败时回 502
|
||||
# 透传错误原文(截断 500 字),绝不回 201 假装建成功——
|
||||
# 注意 _dispatch_safe 的兜底是 500,这里显式 502 优先于它。
|
||||
def _automation_create(self, user_id, project_id):
|
||||
"""创建自动化定义:只登记定义与到期状态,不跑模型、
|
||||
不另起调度器(执行器后续走 claim 门,见 automations
|
||||
模块文档)。body:name、prompt 必填;interval_seconds
|
||||
可选(默认 86400,最小 60);repo_url 可选;
|
||||
first_run_at 可选(epoch 秒或 ISO8601 字符串,与
|
||||
marker 的 parse_first_run_at 同款解析,过去则建完
|
||||
即到期);run_once 可选(true 时跑过一次后不再到期)。
|
||||
|
||||
P1 配额门:创建时先过与建任务同一道配额门(美元配额
|
||||
与无价 token cap,超限 402),避免“定义免费建、到期
|
||||
无限跑”;单自动化的 spend 上限即创建人配额本身——
|
||||
每次到期产出的任务以 created_by=创建人建任务,走
|
||||
POST /tasks 的配额门(触发路径若绕过服务端建任务,
|
||||
则不在本门覆盖内,见残留)。本服务端没有自动化触发
|
||||
端点,触发侧的门不在本次 server.py 改动内。"""
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
if self._reject_if_over_quota(user_id):
|
||||
return
|
||||
interval = body.get("interval_seconds", 86400)
|
||||
if interval is None:
|
||||
interval = 86400
|
||||
first_run_at = body.get("first_run_at")
|
||||
if first_run_at is not None:
|
||||
first_run_at = _parse_first_run_at(first_run_at)
|
||||
if first_run_at is None:
|
||||
self._send_json(400, {"error": "invalid first_run_at"})
|
||||
return
|
||||
run_once = body.get("run_once", False)
|
||||
if not isinstance(run_once, bool):
|
||||
self._send_json(400, {"error": "run_once must be boolean"})
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
try:
|
||||
auto = _auto_create(
|
||||
conn, project_id, body.get("name"),
|
||||
body.get("prompt"), user_id, interval,
|
||||
body.get("repo_url"),
|
||||
first_run_at=first_run_at, run_once=run_once)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)[:500]})
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id, "automation_created",
|
||||
auto["id"])
|
||||
self._send_json(201, auto)
|
||||
|
||||
def _automation_list(self, project_id):
|
||||
"""列出某项目全部自动化定义(含已取消),附到期计数。"""
|
||||
conn = self._db()
|
||||
try:
|
||||
try:
|
||||
autos = _auto_list(conn, project_id)
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)[:500]})
|
||||
return
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
self._send_json(200, {
|
||||
"project_id": project_id,
|
||||
"automations": autos,
|
||||
"due_count": sum(1 for a in autos if a["due"]),
|
||||
})
|
||||
|
||||
# -- 项目待办(见 cloud/todos.py)-------------------------
|
||||
def _todos_list(self, project_id, query):
|
||||
"""列出某项目待办,附全项目各状态计数;query 支持
|
||||
status 与 q(标题/描述子串)服务端过滤,计数与
|
||||
total 不受过滤影响。"""
|
||||
conn = self._db()
|
||||
try:
|
||||
try:
|
||||
todos = _todo_list(
|
||||
conn, project_id,
|
||||
status=query.get("status", [None])[0] or None,
|
||||
q=query.get("q", [None])[0] or None)
|
||||
# counts / total 是全项目口径,不受过滤影响
|
||||
#(与任务列表的 status_counts 同款语义)。
|
||||
all_todos = _todo_list(conn, project_id)
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)[:500]})
|
||||
return
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
counts = {}
|
||||
for t in all_todos:
|
||||
counts[t["status"]] = counts.get(t["status"], 0) + 1
|
||||
self._send_json(200, {
|
||||
"project_id": project_id, "todos": todos,
|
||||
"counts": counts, "total": len(all_todos)})
|
||||
|
||||
def _todos_create(self, user_id, project_id):
|
||||
"""新建待办:body title 必填;description / status /
|
||||
priority / task_id / due_at / sort_order 可选。"""
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
try:
|
||||
todo = _todo_create(
|
||||
conn, project_id, user_id, body.get("title"),
|
||||
description=body.get("description"),
|
||||
status=body.get("status"),
|
||||
priority=body.get("priority"),
|
||||
task_id=body.get("task_id"),
|
||||
due_at=body.get("due_at"),
|
||||
sort_order=body.get("sort_order"))
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)[:500]})
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id, "todo_created", todo["id"])
|
||||
self._send_json(201, todo)
|
||||
|
||||
def _todos_get(self, project_id, todo_id):
|
||||
"""单条待办:行不存在或属于别的项目时一律 404。"""
|
||||
conn = self._db()
|
||||
try:
|
||||
try:
|
||||
todo = _todo_get(conn, todo_id)
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)[:500]})
|
||||
return
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
if todo is None or todo["project_id"] != project_id:
|
||||
self._send_json(404, {"error": "todo not found"})
|
||||
return
|
||||
self._send_json(200, todo)
|
||||
|
||||
def _todos_update(self, user_id, project_id, todo_id):
|
||||
"""PATCH 待办:只改 body 里给出的字段;行不存在或
|
||||
属于别的项目时一律 404;校验失败 400。"""
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
try:
|
||||
todo = _todo_update(conn, todo_id, body or {})
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)[:500]})
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
if todo is None or todo["project_id"] != project_id:
|
||||
self._send_json(404, {"error": "todo not found"})
|
||||
return
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id, "todo_updated", todo_id)
|
||||
self._send_json(200, todo)
|
||||
|
||||
def _todos_delete(self, user_id, project_id, todo_id):
|
||||
"""DELETE 待办(硬删除):行不存在或属于别的项目
|
||||
时一律 404;已取消是软关闭,不走这条。"""
|
||||
conn = self._db()
|
||||
try:
|
||||
try:
|
||||
todo = _todo_delete(conn, todo_id)
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)[:500]})
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
if todo is None or todo["project_id"] != project_id:
|
||||
self._send_json(404, {"error": "todo not found"})
|
||||
return
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id, "todo_deleted", todo_id)
|
||||
self._send_json(200, todo)
|
||||
|
||||
def _automation_cancel(self, user_id, project_id, automation_id):
|
||||
"""取消自动化(软删除):行不存在或属于别的项目时一
|
||||
律 404(不泄露跨项目存在性);已取消时 409。"""
|
||||
conn = self._db()
|
||||
try:
|
||||
try:
|
||||
ok, auto = _auto_cancel(conn, automation_id)
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)[:500]})
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
if auto is None or auto["project_id"] != project_id:
|
||||
self._send_json(404, {"error": "automation not found"})
|
||||
return
|
||||
if not ok:
|
||||
self._send_json(409, {
|
||||
"error": "automation already cancelled"})
|
||||
return
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id, "automation_cancelled",
|
||||
automation_id)
|
||||
self._send_json(200, auto)
|
||||
|
||||
def _automation_get(self, user_id, project_id, automation_id):
|
||||
"""GET 单条自动化(轮询用):定义 + 最近产出记录。
|
||||
行不存在或属于别的项目时一律 404;产出记录读失败
|
||||
时不让整条定义也 502(runs 回空列表 + note)。"""
|
||||
conn = self._db()
|
||||
try:
|
||||
auto = _auto_get(conn, automation_id)
|
||||
runs = []
|
||||
runs_note = ""
|
||||
if auto is not None:
|
||||
try:
|
||||
runs = _auto_runs(conn, automation_id)
|
||||
except Exception:
|
||||
runs_note = "runs unavailable"
|
||||
finally:
|
||||
conn.close()
|
||||
if auto is None or auto["project_id"] != project_id:
|
||||
self._send_json(404, {"error": "automation not found"})
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
binding = _life_get_binding(conn, automation_id)
|
||||
finally:
|
||||
conn.close()
|
||||
self._send_json(200, dict(auto, runs=runs,
|
||||
runs_note=runs_note,
|
||||
heartbeat_binding=binding))
|
||||
|
||||
def _automation_bind_get(self, user_id, project_id, automation_id):
|
||||
conn = self._db()
|
||||
try:
|
||||
auto = _auto_get(conn, automation_id)
|
||||
binding = _life_get_binding(conn, automation_id)
|
||||
finally:
|
||||
conn.close()
|
||||
if auto is None or auto["project_id"] != project_id:
|
||||
self._send_json(404, {"error": "automation not found"})
|
||||
return
|
||||
self._send_json(200, {"automation_id": automation_id,
|
||||
"binding": binding})
|
||||
|
||||
def _automation_bind(self, user_id, project_id, automation_id):
|
||||
"""POST .../automations/{aid}/bind {task_id, mode}:
|
||||
把自动化绑到已有线程。mode=heartbeat 表示到期唤醒
|
||||
该线程(走 thread/message 投递路);mode=cron 表示
|
||||
到期独立跑新任务(绑定只作标记,不唤醒)。目标任务
|
||||
必须在同一项目,否则 404(不泄露跨项目存在性)。
|
||||
权限 create_task;记 automation_bound 审计。"""
|
||||
body, jerr = self._read_json()
|
||||
if jerr:
|
||||
self._send_json(400, {"error": jerr})
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
auto = _auto_get(conn, automation_id)
|
||||
if auto is None or auto["project_id"] != project_id:
|
||||
self._send_json(404, {"error": "automation not found"})
|
||||
return
|
||||
if auto["status"] != "active":
|
||||
self._send_json(409, {
|
||||
"error": "cancelled automation cannot be bound"})
|
||||
return
|
||||
target_id = body.get("task_id")
|
||||
target = self._fetch_task(target_id) \
|
||||
if isinstance(target_id, str) else None
|
||||
if target is None:
|
||||
self._send_json(404, {"error": "task not found"})
|
||||
return
|
||||
target = _task_to_dict(target)
|
||||
if target["project_id"] != project_id:
|
||||
self._send_json(404, {"error": "task not found"})
|
||||
return
|
||||
try:
|
||||
binding = _life_bind(
|
||||
conn, automation_id, project_id, target_id,
|
||||
body.get("mode", "heartbeat"))
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id, "automation_bound",
|
||||
"%s:%s" % (automation_id, target_id))
|
||||
self._send_json(200, {"automation_id": automation_id,
|
||||
"binding": binding})
|
||||
|
||||
def _automation_unbind(self, user_id, project_id, automation_id):
|
||||
conn = self._db()
|
||||
try:
|
||||
auto = _auto_get(conn, automation_id)
|
||||
if auto is None or auto["project_id"] != project_id:
|
||||
self._send_json(404, {"error": "automation not found"})
|
||||
return
|
||||
ok = _life_unbind(conn, automation_id)
|
||||
finally:
|
||||
conn.close()
|
||||
if not ok:
|
||||
self._send_json(404, {"error": "no binding"})
|
||||
return
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id, "automation_unbound",
|
||||
automation_id)
|
||||
self._send_json(200, {"automation_id": automation_id,
|
||||
"deleted": True})
|
||||
|
||||
def _automation_heartbeat(self, user_id, project_id, automation_id):
|
||||
"""POST .../automations/{aid}/heartbeat {message?}:
|
||||
手动触发一次心跳唤醒——到期触发器(host run-due /
|
||||
run_due_automation)调的就是这个口,服务端本身不
|
||||
起定时器。无绑定 409;mode=cron 409(cron 不唤醒
|
||||
线程);message 缺省用自动化 prompt。投递走
|
||||
_deliver_to_thread,与 thread/message 同款复活路,
|
||||
配额门同款。记 automation_heartbeat_sent 审计。"""
|
||||
body, jerr = self._read_json()
|
||||
if jerr:
|
||||
self._send_json(400, {"error": jerr})
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
auto = _auto_get(conn, automation_id)
|
||||
binding = _life_get_binding(conn, automation_id)
|
||||
finally:
|
||||
conn.close()
|
||||
if auto is None or auto["project_id"] != project_id:
|
||||
self._send_json(404, {"error": "automation not found"})
|
||||
return
|
||||
if binding is None:
|
||||
self._send_json(409, {
|
||||
"error": "automation has no heartbeat binding"})
|
||||
return
|
||||
if binding["mode"] != "heartbeat":
|
||||
self._send_json(409, {
|
||||
"error": "cron binding does not wake a thread;"
|
||||
" use the automation run path"})
|
||||
return
|
||||
target_row = self._fetch_task(binding["task_id"])
|
||||
if target_row is None:
|
||||
self._send_json(404, {"error": "bound task not found"})
|
||||
return
|
||||
target = _task_to_dict(target_row)
|
||||
if target["project_id"] != project_id:
|
||||
self._send_json(404, {"error": "bound task not found"})
|
||||
return
|
||||
if self._reject_if_over_quota_for_task(user_id, target):
|
||||
return
|
||||
message = body.get("message") or auto.get("prompt") or ""
|
||||
if not isinstance(message, str) or not message.strip():
|
||||
self._send_json(400, {"error": "message required"})
|
||||
return
|
||||
message = message.strip()
|
||||
if len(message) > 20000:
|
||||
self._send_json(400, {"error": "message too long"})
|
||||
return
|
||||
code, payload = self._deliver_to_thread(
|
||||
user_id, target, message, "queued",
|
||||
audit_action="automation_heartbeat_sent")
|
||||
self._send_json(code, dict(payload,
|
||||
automation_id=automation_id))
|
||||
|
||||
def _automation_update(self, user_id, project_id, automation_id):
|
||||
"""PATCH 单条自动化(automation_update):改名、改
|
||||
prompt、改间隔、改 repo_url,至少给一个字段。注意
|
||||
语义边界:更新不重置 last_run_at(改间隔只影响下次
|
||||
到期,不立即触发);已取消的定义 409(取消是终态,
|
||||
改它必须重建);改名撞同项目 active 名时 400;本
|
||||
端点不改“变化时通知”本身——到期产出的任务终态仍
|
||||
走 worker 现有 push 派发(见 automations 模块文档)。
|
||||
权限 create_task;记 automation_updated 审计。"""
|
||||
body, jerr = self._read_json()
|
||||
if jerr:
|
||||
self._send_json(400, {"error": jerr})
|
||||
return
|
||||
if body.get("first_run_at") is not None and \
|
||||
isinstance(body.get("first_run_at"), str):
|
||||
parsed = _parse_first_run_at(body["first_run_at"])
|
||||
if parsed is None:
|
||||
self._send_json(400, {"error": "invalid first_run_at"})
|
||||
return
|
||||
body = dict(body, first_run_at=parsed)
|
||||
conn = self._db()
|
||||
try:
|
||||
try:
|
||||
ok, auto = _auto_update(conn, automation_id, body)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)[:500]})
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
if auto is None or auto["project_id"] != project_id:
|
||||
self._send_json(404, {"error": "automation not found"})
|
||||
return
|
||||
if not ok:
|
||||
self._send_json(409, {
|
||||
"error": "automation already cancelled"})
|
||||
return
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id, "automation_updated",
|
||||
automation_id)
|
||||
self._send_json(200, auto)
|
||||
|
||||
def _triage_inbox(self, project_id):
|
||||
"""分诊收件箱(只读):到期自动化、失败/中断任务、
|
||||
待处理后续消息、最近自动化产出。见 automations 模块
|
||||
triage_inbox 的字段说明。"""
|
||||
conn = self._db()
|
||||
try:
|
||||
try:
|
||||
_auto_ensure(conn)
|
||||
_ensure_followups_table(conn)
|
||||
inbox = _auto_triage(conn, project_id)
|
||||
except Exception as exc:
|
||||
self._send_json(502, {"error": str(exc)[:500]})
|
||||
return
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
self._send_json(200, inbox)
|
||||
@@ -0,0 +1,420 @@
|
||||
"""Handler mixin:FullAutoMixin。
|
||||
|
||||
由 CloudControlPlaneHandler 拆出,方法体逐字搬移;
|
||||
self.* 调用(含其它 mixin 的方法与留守的 _send_json / _db 等)
|
||||
在混入后的同一类上解析,行为不变。本模块不 import cloud.server。
|
||||
"""
|
||||
|
||||
from cloud.full_auto import (
|
||||
FullAutoDenied,
|
||||
FullAutoError,
|
||||
audit_record,
|
||||
auto_config_plan,
|
||||
branch_protection_payload,
|
||||
ci_fix_decision,
|
||||
is_full_auto_enabled,
|
||||
merge_gate,
|
||||
release_plan,
|
||||
rollback_plan,
|
||||
run_plan,
|
||||
validate_action,
|
||||
)
|
||||
from cloud.github_app import resolve_app_token as _resolve_app_token
|
||||
from cloud.github_app import (
|
||||
find_installation_for_repo,
|
||||
get_app_config,
|
||||
list_installations,
|
||||
)
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
class FullAutoMixin:
|
||||
"""FullAutoMixin 领域方法(见模块 docstring)。"""
|
||||
|
||||
# -- 全自动执行器 HTTP 端点(cloud/full_auto.py 的接线层) ------
|
||||
# 读类端点(status/config-plan)只查计划与状态、不执行、不
|
||||
# 碰 token;写类执行(execute)只限全局管理员,且须同时
|
||||
# 满足:FULL_AUTO_ENABLED 显式开启 + 该仓库 installation 已
|
||||
# 验证 + 主分支保护经 GitHub API 实测达标,三者缺一即
|
||||
# 403 拒绝并记审计,绝不静默放行。审计一律经
|
||||
# full_auto.audit_record 脱敏后落 AuthStore audit_log(action
|
||||
# 前缀 full_auto_),响应与审计里绝不出现 token 明文、
|
||||
# Bearer 明文或调用方塞进 detail 的敏感键。
|
||||
def _full_auto_status(self, user_id, query):
|
||||
repo = (query.get("repo") or [None])[0]
|
||||
conn = self._db()
|
||||
try:
|
||||
configured = get_app_config(conn) is not None
|
||||
verified = _full_auto_installation_verified(conn, repo)
|
||||
installations = list_installations(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
flag = str(os.environ.get("FULL_AUTO_ENABLED", "")
|
||||
).strip().lower() in ("1", "true", "yes")
|
||||
self._send_json(200, {
|
||||
"enabled": is_full_auto_enabled(os.environ, verified),
|
||||
"flag_enabled": flag,
|
||||
"installation_verified": verified,
|
||||
"app_configured": configured,
|
||||
"repo": repo,
|
||||
"installations": installations,
|
||||
})
|
||||
|
||||
def _full_auto_config_plan(self, user_id, query):
|
||||
params, err = _full_auto_parse_plan_params({}, query)
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
try:
|
||||
steps = auto_config_plan(**params)
|
||||
except FullAutoError as exc:
|
||||
self._send_json(400, {"error": str(exc),
|
||||
"reason": exc.reason})
|
||||
return
|
||||
self._send_json(200, {"repo": params["repo_full_name"],
|
||||
"steps": steps})
|
||||
|
||||
def _full_auto_validate_action(self, user_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
action = (body or {}).get("action")
|
||||
if not isinstance(action, str) or not action:
|
||||
self._send_json(400, {"error": "action required"})
|
||||
return
|
||||
try:
|
||||
validate_action(action)
|
||||
except FullAutoDenied as exc:
|
||||
self._send_json(403, {"error": "action denied",
|
||||
"reason": exc.reason,
|
||||
"action": action})
|
||||
return
|
||||
self._send_json(200, {"action": action, "allowed": True})
|
||||
|
||||
def _full_auto_merge_gate(self, user_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
body = body or {}
|
||||
passed, reasons = merge_gate(
|
||||
body.get("checks") or {},
|
||||
body.get("required_checks") or (),
|
||||
bool(body.get("directory_check_ok")),
|
||||
bool(body.get("contract_version_ok")),
|
||||
bool(body.get("coordinator_approved")))
|
||||
self._send_json(200, {"passed": passed, "reasons": reasons})
|
||||
|
||||
def _full_auto_ci_fix(self, user_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
body = body or {}
|
||||
try:
|
||||
attempt = int(body.get("attempt"))
|
||||
except (TypeError, ValueError):
|
||||
self._send_json(400, {"error": "attempt must be an integer"})
|
||||
return
|
||||
self._send_json(200, ci_fix_decision(
|
||||
attempt, body.get("failures") or []))
|
||||
|
||||
def _full_auto_release_plan(self, user_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
body = body or {}
|
||||
if not isinstance(body.get("tag"), str) or not body.get("tag"):
|
||||
self._send_json(400, {"error": "tag required"})
|
||||
return
|
||||
self._send_json(200, release_plan(
|
||||
body["tag"], body.get("merged_prs") or []))
|
||||
|
||||
def _full_auto_rollback_plan(self, user_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
body = body or {}
|
||||
try:
|
||||
plan = rollback_plan(body.get("previous_tag"))
|
||||
except FullAutoError as exc:
|
||||
self._send_json(400, {"error": str(exc),
|
||||
"reason": exc.reason})
|
||||
return
|
||||
self._send_json(200, plan)
|
||||
|
||||
def _full_auto_audit_list(self, user_id, query):
|
||||
"""审计查询:只限全局管理员,只返回 full_auto_ 前缀行,
|
||||
且只返回已脱敏落盘的 detail(写时已脱敏,读时不再
|
||||
含 token——调用方若直接写库绕过本层,不在本端点
|
||||
的保证内,写路径见 _full_auto_execute)。"""
|
||||
if not self._is_global_admin(user_id):
|
||||
self._audit_admin(user_id, "full_auto_audit_denied",
|
||||
"audit list")
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
try:
|
||||
limit = int((query.get("limit") or ["100"])[0])
|
||||
except ValueError:
|
||||
limit = 100
|
||||
limit = max(1, min(limit, 100))
|
||||
conn = self._db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT user_id, action, detail, created_at FROM audit_log"
|
||||
" WHERE action LIKE 'full_auto_%'"
|
||||
" ORDER BY created_at DESC, id DESC LIMIT ?",
|
||||
(limit,)).fetchall()
|
||||
except Exception:
|
||||
rows = []
|
||||
finally:
|
||||
conn.close()
|
||||
self._send_json(200, {"audit": [
|
||||
{"user_id": r[0], "action": r[1], "detail": r[2],
|
||||
"created_at": r[3]} for r in rows]})
|
||||
|
||||
def _full_auto_execute(self, user_id):
|
||||
"""执行自动配置计划:按 full_auto.auto_config_plan 生成
|
||||
顺序计划,按 full_auto.run_plan 顺序执行,不跳步。
|
||||
|
||||
门禁顺序(任一步拒绝即停、记审计、不执行后续):
|
||||
1. 全局管理员;2. body.action 若给出须过 validate_action;
|
||||
3. FULL_AUTO_ENABLED 显式开 + 该仓库 installation 已验证;
|
||||
4. installation token 现解析现用(测试注入
|
||||
server.full_auto_token_resolver 仅供单测绕过 JWT 签发,
|
||||
生产为 None 时走 github_app.resolve_app_token);
|
||||
5. 主分支保护经 GitHub API 实测达标(带 token 查,
|
||||
私有仓无 token 查保护必 404,故 token 先于保护核查)。
|
||||
token 只进 Authorization 请求头;request_fn 注入点
|
||||
server.full_auto_http_client 仅供单测,生产走 urllib。"""
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
body = body or {}
|
||||
params, perr = _full_auto_parse_plan_params(body, {})
|
||||
if perr:
|
||||
self._send_json(400, {"error": perr})
|
||||
return
|
||||
repo = params["repo_full_name"]
|
||||
branch = params["default_branch"]
|
||||
checks = params["required_checks"]
|
||||
|
||||
def _deny(reason, detail):
|
||||
record = audit_record("execute_denied", repo,
|
||||
"full-auto-executor", "denied",
|
||||
{"reason": reason, "detail": detail})
|
||||
self._audit_admin(user_id, "full_auto_execute_denied",
|
||||
json.dumps(record, ensure_ascii=False))
|
||||
self._send_json(403, {"error": "full-auto denied",
|
||||
"reason": reason})
|
||||
if not self._is_global_admin(user_id):
|
||||
_deny("not_global_admin", "execute requires global admin")
|
||||
return
|
||||
action = body.get("action")
|
||||
if action is not None:
|
||||
if not isinstance(action, str) or not action:
|
||||
self._send_json(400, {"error": "action must be a string"})
|
||||
return
|
||||
try:
|
||||
validate_action(action)
|
||||
except FullAutoDenied as exc:
|
||||
_deny(exc.reason, "action=%s" % action)
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
verified = _full_auto_installation_verified(conn, repo)
|
||||
finally:
|
||||
conn.close()
|
||||
if not is_full_auto_enabled(os.environ, verified):
|
||||
_deny("full_auto_disabled",
|
||||
"FULL_AUTO_ENABLED not set or installation unverified")
|
||||
return
|
||||
http_client = getattr(self.server, "full_auto_http_client", None)
|
||||
request_fn = lambda m, u, h, b: _full_auto_github_request( # noqa: E731
|
||||
http_client, m, u, h, b)
|
||||
resolver = getattr(self.server, "full_auto_token_resolver", None)
|
||||
if resolver is not None:
|
||||
token = resolver(self.server.db_path, repo)
|
||||
else:
|
||||
conn = self._db()
|
||||
try:
|
||||
token = _resolve_app_token(conn, repo)
|
||||
finally:
|
||||
conn.close()
|
||||
if not token:
|
||||
_deny("token_missing",
|
||||
"installation token unavailable for repo")
|
||||
return
|
||||
protection_url = ("https://api.github.com/repos/%s/branches/%s"
|
||||
"/protection" % (repo, branch))
|
||||
try:
|
||||
pstatus, protection = request_fn(
|
||||
"GET", protection_url,
|
||||
{"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "codex-app-server",
|
||||
"Authorization": "Bearer " + token}, None)
|
||||
except FullAutoError:
|
||||
_deny("branch_protection_unverified",
|
||||
"protection check request failed")
|
||||
return
|
||||
if pstatus != 200 or not _full_auto_branch_protection_ok(
|
||||
protection, checks):
|
||||
_deny("branch_protection_unverified",
|
||||
"branch protection incomplete or unverified")
|
||||
return
|
||||
try:
|
||||
steps = auto_config_plan(**params)
|
||||
except FullAutoError as exc:
|
||||
self._send_json(400, {"error": str(exc),
|
||||
"reason": exc.reason})
|
||||
return
|
||||
results = []
|
||||
try:
|
||||
results = run_plan(request_fn, token, steps)
|
||||
except FullAutoDenied as exc:
|
||||
_deny(exc.reason, "plan step denied")
|
||||
return
|
||||
except FullAutoError as exc:
|
||||
record = audit_record("auto_config", repo,
|
||||
"full-auto-executor", "failed",
|
||||
{"reason": exc.reason,
|
||||
"detail": body.get("detail")})
|
||||
self._audit_admin(user_id, "full_auto_auto_config",
|
||||
json.dumps(record, ensure_ascii=False))
|
||||
self._send_json(502, {"error": str(exc),
|
||||
"reason": exc.reason,
|
||||
"steps": results})
|
||||
return
|
||||
record = audit_record("auto_config", repo,
|
||||
"full-auto-executor", "ok",
|
||||
{"detail": body.get("detail"),
|
||||
"steps": [r["step"] for r in results]})
|
||||
self._audit_admin(user_id, "full_auto_auto_config",
|
||||
json.dumps(record, ensure_ascii=False))
|
||||
# 响应只含步骤名/状态码/ok,绝不回显请求体 detail、
|
||||
# headers 或 token——results 本身就是 run_plan 的
|
||||
# (step/status/ok) 三元组,无凭据字段。
|
||||
self._send_json(200, {"ok": True, "repo": repo, "steps": results})
|
||||
|
||||
|
||||
def _full_auto_parse_plan_params(body, query):
|
||||
"""从 POST body 或 GET query 里解析 auto_config_plan 参数。
|
||||
|
||||
repo 必填且须含 /;required_checks 可为列表或逗号字符串;
|
||||
布尔字段接受 true/false/1/0 字符串与布尔值。非法返回
|
||||
(None, error_string)。"""
|
||||
def _get(name):
|
||||
if isinstance(body, dict) and name in body:
|
||||
return body[name]
|
||||
values = (query or {}).get(name)
|
||||
return values[0] if values else None
|
||||
|
||||
def _as_bool(value, default):
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in ("1", "true", "yes")
|
||||
|
||||
repo = _get("repo") or _get("repo_full_name")
|
||||
if not isinstance(repo, str) or "/" not in repo:
|
||||
return None, "repo must be owner/repo"
|
||||
checks = _get("required_checks")
|
||||
if checks is None:
|
||||
checks = ("verify",)
|
||||
elif isinstance(checks, str):
|
||||
checks = tuple(c.strip() for c in checks.split(",") if c.strip())
|
||||
elif isinstance(checks, (list, tuple)):
|
||||
checks = tuple(str(c) for c in checks if str(c))
|
||||
else:
|
||||
return None, "required_checks must be a list or string"
|
||||
params = {
|
||||
"repo_full_name": repo,
|
||||
"default_branch": _get("default_branch") or "main",
|
||||
"org": _get("org"),
|
||||
"repo_exists": _as_bool(_get("repo_exists"), True),
|
||||
"ci_workflow_present": _as_bool(_get("ci_workflow_present"), False),
|
||||
"required_checks": checks,
|
||||
}
|
||||
if params["org"] is not None and not isinstance(params["org"], str):
|
||||
return None, "org must be a string"
|
||||
return params, None
|
||||
|
||||
def _full_auto_installation_verified(conn, repo_full_name=None):
|
||||
"""installation 已验证 = App 配置存在 + 安装记录存在。
|
||||
|
||||
repo 给出时查该仓库的安装记录(用户在 GitHub 侧安装 App
|
||||
后 webhook 落库的记录);repo 未给出时任一安装记录即算
|
||||
已验证(仅供 /full-auto/status 总览用,执行仍须按仓库查)。
|
||||
真实安装/重新接受只能由用户在 GitHub 侧完成,本层绝不
|
||||
伪造安装记录、绝不把“无记录”说成已验证。"""
|
||||
try:
|
||||
if get_app_config(conn) is None:
|
||||
return False
|
||||
if repo_full_name:
|
||||
return find_installation_for_repo(conn, repo_full_name) is not None
|
||||
rows = list_installations(conn)
|
||||
return bool(rows)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _full_auto_branch_protection_ok(protection, required_checks):
|
||||
"""分支保护是否达到 full_auto 的强制标准:与
|
||||
branch_protection_payload 的要求逐项核对,缺一项即不算
|
||||
配好;required_checks 须全部在 contexts 里(可多不可少)。
|
||||
protection 为非 dict 或 GET 失败时调用方已先行拒绝。"""
|
||||
if not isinstance(protection, dict):
|
||||
return False
|
||||
try:
|
||||
expected = branch_protection_payload(required_checks)
|
||||
except FullAutoError:
|
||||
return False
|
||||
checks = protection.get("required_status_checks") or {}
|
||||
if not isinstance(checks, dict) or not checks.get("strict"):
|
||||
return False
|
||||
contexts = checks.get("contexts") or []
|
||||
if not set(expected["required_status_checks"]["contexts"]) <= set(contexts):
|
||||
return False
|
||||
if not protection.get("enforce_admins"):
|
||||
return False
|
||||
if not isinstance(protection.get("required_pull_request_reviews"), dict):
|
||||
return False
|
||||
if protection.get("allow_force_pushes") is not False:
|
||||
return False
|
||||
if protection.get("allow_deletions") is not False:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _full_auto_github_request(http_client, method, url, headers, body):
|
||||
"""full_auto.run_plan 用的 request_fn:注入的 fake 客户端
|
||||
优先(单测);生产走 urllib,token 只在 headers 里,响应
|
||||
只返回 (status, dict),绝不把 headers 写进返回值。"""
|
||||
if http_client is not None:
|
||||
result = http_client(method, url, headers, body)
|
||||
if not (isinstance(result, tuple) and len(result) == 2):
|
||||
raise FullAutoError("http_client must return (status, dict)",
|
||||
reason="auto_config_step_failed")
|
||||
return result
|
||||
request = urllib.request.Request(url, data=body, method=method,
|
||||
headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
raw = response.read().decode("utf-8", "replace")
|
||||
status = response.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8", "replace")
|
||||
status = exc.code
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except ValueError:
|
||||
parsed = {}
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {}
|
||||
return status, parsed
|
||||
@@ -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],
|
||||
})
|
||||
@@ -0,0 +1,634 @@
|
||||
"""Handler mixin:OAuthMixin。
|
||||
|
||||
由 CloudControlPlaneHandler 拆出,方法体逐字搬移;
|
||||
self.* 调用(含其它 mixin 的方法与留守的 _send_json / _db 等)
|
||||
在混入后的同一类上解析,行为不变。本模块不 import cloud.server。
|
||||
"""
|
||||
|
||||
from cloud.github_app import (
|
||||
DEFAULT_APP_NAME,
|
||||
DEFAULT_APP_URL,
|
||||
AppError,
|
||||
SetupStateStore,
|
||||
build_app_manifest,
|
||||
exchange_manifest_code,
|
||||
list_installations,
|
||||
public_app_status,
|
||||
save_app_config,
|
||||
)
|
||||
from cloud.oauth import (
|
||||
BindError,
|
||||
OAuthError,
|
||||
OAuthStateStore,
|
||||
bind_external_identity,
|
||||
build_authorization_url,
|
||||
default_userinfo_client,
|
||||
find_bound_user,
|
||||
handle_oauth_callback,
|
||||
list_bindings,
|
||||
map_external_identity_to_local_user_with_key,
|
||||
start_oauth_login,
|
||||
unbind_external_identity,
|
||||
)
|
||||
from cloud.sub2api import ensure_user_gateway_key as _ensure_sub2api_key
|
||||
from cloud.sub2api import gateway_origin as _gateway_origin
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
from cloud.handler_support import (
|
||||
_apply_default_quota as _apply_default_quota,
|
||||
_auth_store as _auth_store,
|
||||
_default_quota_usd_micros as _default_quota_usd_micros,
|
||||
_global_admin_ids as _global_admin_ids,
|
||||
)
|
||||
|
||||
|
||||
class OAuthMixin:
|
||||
"""OAuthMixin 领域方法(见模块 docstring)。"""
|
||||
|
||||
# -- OAuth(pre-auth,靠 state + secret,不走 X-API-Key)-----------
|
||||
def _oauth_provider(self, path):
|
||||
parts = [p for p in path.split("/") if p]
|
||||
if len(parts) != 3 or parts[0] != "oauth":
|
||||
return None
|
||||
return parts[1]
|
||||
|
||||
def _oauth_login(self, path):
|
||||
provider = self._oauth_provider(path)
|
||||
if provider is None:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
query = parse_qs(urlparse(self.path).query)
|
||||
client_id = (query.get("client_id") or [None])[0]
|
||||
redirect_uri = (query.get("redirect_uri") or [None])[0]
|
||||
scope = (query.get("scope") or [None])[0]
|
||||
if not client_id or not redirect_uri:
|
||||
self._send_json(400, {"error": "client_id and redirect_uri required"})
|
||||
return
|
||||
store = OAuthStateStore(self.server.db_path)
|
||||
try:
|
||||
state, url = start_oauth_login(
|
||||
store, provider, client_id, redirect_uri, scope=scope)
|
||||
except OAuthError as e:
|
||||
self._send_json(400, {"error": str(e), "reason": e.reason})
|
||||
return
|
||||
except ValueError as e:
|
||||
self._send_json(400, {"error": str(e)})
|
||||
return
|
||||
finally:
|
||||
try:
|
||||
store.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
# 只返回 URL,不直接跳转到任何登录页;跳转由调用方决定。
|
||||
self._send_json(200, {"provider": provider, "state": state,
|
||||
"authorization_url": url})
|
||||
|
||||
def _oauth_clients(self, provider):
|
||||
"""回调用的 http/userinfo 客户端:测试注入优先,生产走真实路径。
|
||||
|
||||
server 属性 oauth_http_client / oauth_userinfo_client 由
|
||||
create_server 置为 None(生产)或由测试注入 fake;http_client
|
||||
已注入(fake)而 userinfo 未注入时不走真实 userinfo(fake
|
||||
token 自带身份字段时不需要它,走真实端点反而会用假 token
|
||||
撞真实 GitHub)。生产(两者都未注入)时 userinfo 走真实端点。
|
||||
"""
|
||||
http_client = getattr(self.server, "oauth_http_client", None)
|
||||
userinfo_client = getattr(self.server, "oauth_userinfo_client", None)
|
||||
if userinfo_client is None and http_client is None:
|
||||
userinfo_client = default_userinfo_client(provider)
|
||||
return http_client, userinfo_client
|
||||
|
||||
def _oauth_finish(self, provider, code, state, client_id, redirect_uri):
|
||||
"""回调共用后半段:验 state -> 换 token -> 取身份 -> 分叉。
|
||||
|
||||
成功返回 6 元组
|
||||
``(local_id, created, api_key_plain, rotated, identity, is_bind)``;
|
||||
失败抛 OAuthError(调用方按 JSON/HTML 分别呈现,绝不把
|
||||
access_token、client_secret、旧 API key 写进任何响应),
|
||||
绑定冲突抛 BindError(调用方按 409 呈现)。
|
||||
client_id / redirect_uri 为 None 时由 handle_oauth_callback
|
||||
从 state 记录取回(真实浏览器回调只带 code + state)。
|
||||
|
||||
分叉规则(顺序即优先级):
|
||||
1. state 带 bind_user_id -> 绑定分支:把身份绑到该本站
|
||||
账号,返回 is_bind=True、api_key_plain=None(绑定成功页
|
||||
绝不显示任何 key);冲突抛 BindError;
|
||||
2. 身份已绑定到某本站账号 -> 登录落到被绑账号,并按
|
||||
OAuth 登录规矩签发附加 key(created=False、rotated
|
||||
恒为 False),与 oauth 账号登录同一语义,不发明新语义;
|
||||
3. 否则走原来的确定性 oauth 账号映射(新建/复用 + 签发
|
||||
附加 key)。
|
||||
"""
|
||||
store = OAuthStateStore(self.server.db_path)
|
||||
try:
|
||||
http_client, userinfo_client = self._oauth_clients(provider)
|
||||
identity = handle_oauth_callback(
|
||||
store, provider, code, state, client_id, redirect_uri,
|
||||
http_client=http_client, userinfo_client=userinfo_client)
|
||||
finally:
|
||||
try:
|
||||
store.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
bind_user_id = identity.get("bind_user_id")
|
||||
if bind_user_id:
|
||||
binding = bind_external_identity(
|
||||
auth, provider, identity, bind_user_id)
|
||||
auth.audit(bind_user_id, "", "bind_external",
|
||||
"%s:%s" % (provider, identity.get("external_id")))
|
||||
if _apply_default_quota(auth.conn, bind_user_id):
|
||||
auth.audit(bind_user_id, "", "quota_default",
|
||||
"quota_usd_micros=%d"
|
||||
% _default_quota_usd_micros())
|
||||
identity["already_bound"] = binding["already"]
|
||||
return (bind_user_id, False, None, False, identity, True)
|
||||
bound_user_id = find_bound_user(auth, provider, identity)
|
||||
if bound_user_id is not None:
|
||||
_key_id, api_key_plain = auth.create_api_key(
|
||||
bound_user_id, "oauth:%s" % provider)
|
||||
auth.audit(bound_user_id, "", "oauth_login",
|
||||
"%s:%s" % (provider, identity.get("external_id")))
|
||||
auth.audit(bound_user_id, "", "api_key_issued", provider)
|
||||
if _apply_default_quota(auth.conn, bound_user_id):
|
||||
auth.audit(bound_user_id, "", "quota_default",
|
||||
"quota_usd_micros=%d"
|
||||
% _default_quota_usd_micros())
|
||||
return (bound_user_id, False, api_key_plain, False, identity,
|
||||
False)
|
||||
local_id, created, api_key_plain, rotated = \
|
||||
map_external_identity_to_local_user_with_key(
|
||||
auth, provider, identity)
|
||||
auth.audit(local_id, "", "oauth_login",
|
||||
"%s:%s" % (provider, identity.get("external_id")))
|
||||
if _apply_default_quota(auth.conn, local_id):
|
||||
auth.audit(local_id, "", "quota_default",
|
||||
"quota_usd_micros=%d"
|
||||
% _default_quota_usd_micros())
|
||||
if created and _gateway_origin() is not None:
|
||||
# sub2api 开通(best-effort):建号不因网关开通失败
|
||||
# 而失败,任务时 _ensure_gateway 会懒开通兜底。
|
||||
try:
|
||||
_ensure_sub2api_key(auth.conn, local_id)
|
||||
except Exception as exc:
|
||||
auth.audit(local_id, "", "sub2api_provision_failed",
|
||||
str(exc)[:200])
|
||||
if not created:
|
||||
auth.audit(local_id, "", "api_key_issued", provider)
|
||||
return local_id, created, api_key_plain, rotated, identity, False
|
||||
|
||||
def _oauth_callback(self, path):
|
||||
provider = self._oauth_provider(path)
|
||||
if provider is None:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
code = body.get("code")
|
||||
state = body.get("state")
|
||||
client_id = body.get("client_id")
|
||||
redirect_uri = body.get("redirect_uri")
|
||||
if not code or not state:
|
||||
self._send_json(400, {"error": "code and state required"})
|
||||
return
|
||||
try:
|
||||
local_id, created, api_key_plain, rotated, identity, is_bind = \
|
||||
self._oauth_finish(provider, code, state, client_id,
|
||||
redirect_uri)
|
||||
except BindError as e:
|
||||
self._send_json(409, {"error": str(e), "reason": e.reason})
|
||||
return
|
||||
except OAuthError as e:
|
||||
self._send_json(400, {"error": str(e), "reason": e.reason})
|
||||
return
|
||||
if is_bind:
|
||||
# 绑定成功:绝不返回任何 key(绑定页的 completer 未必是
|
||||
# 发起人,key 一旦上页就可能落到别人手里);发起人用
|
||||
# 自己的 key 调 GET /oauth/{provider}/bindings 核对。
|
||||
self._send_json(200, {"linked": True, "provider": provider,
|
||||
"login": identity.get("login"),
|
||||
"external_id": identity.get("external_id"),
|
||||
"user_id": local_id,
|
||||
"already_bound":
|
||||
identity.get("already_bound", False)})
|
||||
return
|
||||
# api_key 明文只在这里出现一次:调用方须立即保存,
|
||||
# 之后任何接口都不再返回它;登录签发的是附加 key,旧 key
|
||||
# 仍可继续使用(多 key;不用的旧 key 可在 keys 管理里撤销)。
|
||||
self._send_json(200, {"user_id": local_id, "created": created,
|
||||
"provider": provider,
|
||||
"login": identity.get("login"),
|
||||
"email": identity.get("email"),
|
||||
"api_key": api_key_plain,
|
||||
"api_key_rotated": rotated})
|
||||
|
||||
def _oauth_error_page(self, reason, message):
|
||||
"""GET 回调的 400 错误页:只含 reason 与提示,不含任何密钥、
|
||||
token 或旧 API key(message 来自 OAuthError,本身不含密钥,
|
||||
仍做 HTML 转义)。"""
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<title>OAuth login failed</title></head><body>"
|
||||
"<h1>OAuth login failed</h1>"
|
||||
"<p>reason: %s</p><p>%s</p>"
|
||||
"<p>Please restart login from the login page.</p>"
|
||||
"</body></html>"
|
||||
% (html.escape(str(reason or "error")),
|
||||
html.escape(str(message))))
|
||||
|
||||
def _oauth_success_page(self, provider, local_id, created, login,
|
||||
api_key_plain, rotated):
|
||||
"""GET 回调的成功页:只显示 login、user_id、created 与新 API key
|
||||
一次;绝不显示 access_token、client_secret、旧 API key。
|
||||
|
||||
rotated 只为向后兼容保留(登录不再轮换,恒为 False):
|
||||
新 key 是附加 key,旧 key 仍可继续使用,页面不再写“旧 key
|
||||
已失效”,而是说明多 key 与撤销入口。
|
||||
"""
|
||||
rotation_note = (
|
||||
"<p>Your previous API key has been rotated and no longer works.</p>"
|
||||
if rotated else
|
||||
"<p>Previous keys still work (multi-key). Revoke unused keys "
|
||||
"via the API keys management endpoints.</p>"
|
||||
if not created else "")
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<title>OAuth login succeeded</title></head><body>"
|
||||
"<h1>OAuth login succeeded</h1>"
|
||||
"<p>provider: %s</p>"
|
||||
"<p>login: %s</p>"
|
||||
"<p>user_id: %s</p>"
|
||||
"<p>new user: %s</p>"
|
||||
"%s"
|
||||
"<p>Your API key (shown only once, save it now):</p>"
|
||||
"<p><code>%s</code></p>"
|
||||
"<p>Please save it immediately. It will not be shown again "
|
||||
"after you leave or refresh this page."
|
||||
"</p>"
|
||||
"<p>Use it as the X-API-Key header for API calls.</p>"
|
||||
"</body></html>"
|
||||
% (html.escape(str(provider)),
|
||||
html.escape(str(login)),
|
||||
html.escape(str(local_id)),
|
||||
"yes" if created else "no",
|
||||
rotation_note,
|
||||
html.escape(str(api_key_plain))))
|
||||
|
||||
def _oauth_bind_success_page(self, provider, login):
|
||||
"""绑定成功页:只显示 provider + 外部 login + 成功提示。
|
||||
|
||||
绝不显示任何 key,也不显示本站 user_id:回调页的 completer
|
||||
未必是绑定发起人,key 与内部 id 一旦上页就可能落到别人
|
||||
手里;发起人用自己的 key 调 GET /oauth/{provider}/bindings
|
||||
核对绑定。
|
||||
"""
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<title>OAuth binding succeeded</title></head><body>"
|
||||
"<h1>OAuth binding succeeded</h1>"
|
||||
"<p>provider: %s</p>"
|
||||
"<p>login: %s</p>"
|
||||
"<p>Your external identity is now bound to your account.</p>"
|
||||
"<p>You can verify it from your bindings list.</p>"
|
||||
"</body></html>"
|
||||
% (html.escape(str(provider)),
|
||||
html.escape(str(login))))
|
||||
|
||||
def _oauth_callback_get(self, path):
|
||||
provider = self._oauth_provider(path)
|
||||
if provider is None:
|
||||
self._send_html(404, self._oauth_error_page(
|
||||
"not_found", "unknown oauth path"))
|
||||
return
|
||||
query = parse_qs(urlparse(self.path).query)
|
||||
code = (query.get("code") or [None])[0]
|
||||
state = (query.get("state") or [None])[0]
|
||||
client_id = (query.get("client_id") or [None])[0]
|
||||
redirect_uri = (query.get("redirect_uri") or [None])[0]
|
||||
if not code or not state:
|
||||
self._send_html(400, self._oauth_error_page(
|
||||
"missing_params", "code and state required"))
|
||||
return
|
||||
try:
|
||||
local_id, created, api_key_plain, rotated, identity, is_bind = \
|
||||
self._oauth_finish(provider, code, state, client_id,
|
||||
redirect_uri)
|
||||
except BindError as e:
|
||||
self._send_html(409, self._oauth_error_page(
|
||||
e.reason or "bind_conflict", str(e)))
|
||||
return
|
||||
except OAuthError as e:
|
||||
self._send_html(400, self._oauth_error_page(
|
||||
e.reason or "oauth_failed", str(e)))
|
||||
return
|
||||
if is_bind:
|
||||
self._send_html(200, self._oauth_bind_success_page(
|
||||
provider, identity.get("login")))
|
||||
return
|
||||
self._send_html(200, self._oauth_success_page(
|
||||
provider, local_id, created, identity.get("login"),
|
||||
api_key_plain, rotated))
|
||||
|
||||
# -- OAuth 绑定(已有账号绑外部身份)------------------------------
|
||||
def _oauth_bind_route(self, path):
|
||||
"""绑定相关路径解析:返回 (provider, action) 或 None。
|
||||
|
||||
POST /oauth/{provider}/bind/initiate -> (provider, "bind_initiate")
|
||||
GET|DELETE /oauth/{provider}/bindings -> (provider, "bindings")
|
||||
其它 /oauth/ 路径返回 None,走原来的登录/回调分发。
|
||||
"""
|
||||
parts = [p for p in path.split("/") if p]
|
||||
if len(parts) == 4 and parts[0] == "oauth" \
|
||||
and parts[2] == "bind" and parts[3] == "initiate":
|
||||
return parts[1], "bind_initiate"
|
||||
if len(parts) == 3 and parts[0] == "oauth" \
|
||||
and parts[2] == "bindings":
|
||||
return parts[1], "bindings"
|
||||
return None
|
||||
|
||||
def _oauth_bind_initiate(self, provider):
|
||||
"""绑定起点:须持本站 key(与登录不同,绑定必须知道绑到谁)。
|
||||
|
||||
body 须含 client_id + redirect_uri(与登录同款);state 里
|
||||
带 bind_user_id,回调时靠它走绑定分支。只返回授权 URL,
|
||||
不直接跳转;跳转由调用方决定。
|
||||
"""
|
||||
user_id = self._authenticate()
|
||||
if user_id is None:
|
||||
self._send_json(401, {"error": "unauthorized"})
|
||||
return
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
client_id = body.get("client_id")
|
||||
redirect_uri = body.get("redirect_uri")
|
||||
scope = body.get("scope")
|
||||
if not client_id or not redirect_uri:
|
||||
self._send_json(400, {"error": "client_id and redirect_uri required"})
|
||||
return
|
||||
store = OAuthStateStore(self.server.db_path)
|
||||
try:
|
||||
state = store.create_state(
|
||||
provider, redirect_uri, client_id=client_id,
|
||||
bind_user_id=user_id)
|
||||
url = build_authorization_url(
|
||||
provider, client_id, redirect_uri, scope=scope, state=state)
|
||||
except OAuthError as e:
|
||||
self._send_json(400, {"error": str(e), "reason": e.reason})
|
||||
return
|
||||
except ValueError as e:
|
||||
self._send_json(400, {"error": str(e)})
|
||||
return
|
||||
finally:
|
||||
try:
|
||||
store.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._send_json(200, {"provider": provider, "state": state,
|
||||
"authorization_url": url})
|
||||
|
||||
def _oauth_bindings_get(self, provider):
|
||||
"""列出调用方自己的绑定(只看得到自己的,别人的看不到)。"""
|
||||
user_id = self._authenticate()
|
||||
if user_id is None:
|
||||
self._send_json(401, {"error": "unauthorized"})
|
||||
return
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
bindings = list_bindings(auth, user_id)
|
||||
self._send_json(200, {"provider": provider, "bindings": bindings})
|
||||
|
||||
def _oauth_bindings_delete(self, provider):
|
||||
"""解绑调用方自己在该 provider 上的绑定:本来就没有返回 404。
|
||||
|
||||
解绑后该身份登录恢复原来的 oauth 账号映射(旧 oauth 账号
|
||||
若存在,key 仍可用,不会被解绑删掉)。
|
||||
"""
|
||||
user_id = self._authenticate()
|
||||
if user_id is None:
|
||||
self._send_json(401, {"error": "unauthorized"})
|
||||
return
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
ok = unbind_external_identity(auth, provider, user_id)
|
||||
if ok:
|
||||
auth.audit(user_id, "", "unbind_external", provider)
|
||||
if not ok:
|
||||
self._send_json(404, {"error": "no binding for provider"})
|
||||
return
|
||||
self._send_json(200, {"unbound": True, "provider": provider})
|
||||
|
||||
# -- GitHub App 一键安装 ------------------------------------------
|
||||
def _github_app_public_base(self):
|
||||
base = os.environ.get("GITHUB_APP_PUBLIC_BASE") or ""
|
||||
return base.rstrip("/") or None
|
||||
|
||||
def _github_app_error_page(self, reason, message):
|
||||
"""App 流程错误页:只含 reason 与提示,不含任何 secret、
|
||||
pem、code、state(message 来自流程错误,本身不含密钥,
|
||||
仍做 HTML 转义)。"""
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<title>GitHub App setup failed</title></head><body>"
|
||||
"<h1>GitHub App setup failed</h1>"
|
||||
"<p>reason: %s</p><p>%s</p>"
|
||||
"</body></html>"
|
||||
% (html.escape(str(reason or "error")),
|
||||
html.escape(str(message))))
|
||||
|
||||
def _github_app_setup_state(self):
|
||||
"""签发 setup state:只限全局管理员(见 _is_global_admin)。
|
||||
|
||||
P0 安全批前任何持 key 用户都能签发并回调覆盖全局
|
||||
App 配置;现在非管理员直接 403 并记审计。要求环境
|
||||
变量 GITHUB_APP_PUBLIC_BASE(公网域名,如
|
||||
https://api.diante.me):manifest 里的 hook / 回调地址
|
||||
都由它拼出。返回 {state, setup_url},setup_url 是用户
|
||||
在浏览器里打开的表单页;state 一次性、1 小时过期,
|
||||
且绑定签发人。
|
||||
"""
|
||||
user_id = self._authenticate()
|
||||
if user_id is None:
|
||||
self._send_json(401, {"error": "unauthorized"})
|
||||
return
|
||||
if not self._is_global_admin(user_id):
|
||||
self._audit_admin(user_id, "github_app_setup_denied",
|
||||
"setup state")
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
base = self._github_app_public_base()
|
||||
if not base:
|
||||
self._send_json(400, {
|
||||
"error": "GITHUB_APP_PUBLIC_BASE not configured"})
|
||||
return
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
body = {}
|
||||
org = (body or {}).get("org")
|
||||
store = SetupStateStore(self.server.db_path)
|
||||
try:
|
||||
state = store.create_state(issued_by=user_id)
|
||||
finally:
|
||||
try:
|
||||
store.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
setup_url = base + "/github-app/setup?state=" + state
|
||||
if org:
|
||||
setup_url += "&org=" + quote(str(org))
|
||||
self._send_json(200, {"state": state, "setup_url": setup_url})
|
||||
|
||||
def _github_app_setup_page(self):
|
||||
"""建 App 表单页:公开页,但 state 必须是操作方签发的。
|
||||
|
||||
无 state 或 state 未签发时不渲染表单(state 是回调的
|
||||
唯一凭证,公开页自己现发 state 等于谁都能覆盖 App 配置)。
|
||||
表单按 GitHub manifest 文档 POST 到 github.com 的建 App
|
||||
页(个人账号 /settings/apps/new,组织账号
|
||||
/organizations/{org}/settings/apps/new),manifest JSON
|
||||
由 JS 填进隐藏字段;用户在 GitHub 页确认 Create 后跳回
|
||||
本站 /github-app/callback。
|
||||
"""
|
||||
query = parse_qs(urlparse(self.path).query)
|
||||
state = (query.get("state") or [None])[0]
|
||||
org = (query.get("org") or [None])[0]
|
||||
if not state:
|
||||
self._send_html(400, self._github_app_error_page(
|
||||
"missing_state",
|
||||
"setup state required: ask the operator to issue one"))
|
||||
return
|
||||
base = self._github_app_public_base()
|
||||
if not base:
|
||||
self._send_html(400, self._github_app_error_page(
|
||||
"not_configured", "GITHUB_APP_PUBLIC_BASE not configured"))
|
||||
return
|
||||
manifest = build_app_manifest(
|
||||
base,
|
||||
app_name=os.environ.get("GITHUB_APP_NAME") or DEFAULT_APP_NAME,
|
||||
app_url=os.environ.get("GITHUB_APP_URL") or DEFAULT_APP_URL,
|
||||
public=os.environ.get("GITHUB_APP_PUBLIC") == "1")
|
||||
manifest_json = json.dumps(manifest).replace("<", "\\u003c")
|
||||
if org:
|
||||
action = ("https://github.com/organizations/%s"
|
||||
"/settings/apps/new?state=%s"
|
||||
% (quote(str(org)),
|
||||
quote(state)))
|
||||
else:
|
||||
action = ("https://github.com/settings/apps/new?state=%s"
|
||||
% quote(state))
|
||||
perms = ", ".join("%s: %s" % kv
|
||||
for kv in manifest["default_permissions"].items())
|
||||
events = ", ".join(manifest["default_events"])
|
||||
page = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<title>Create GitHub App</title></head><body>"
|
||||
"<h1>Create GitHub App</h1>"
|
||||
"<p>App name: %s</p>"
|
||||
"<p>Webhook URL: %s</p>"
|
||||
"<p>Permissions: %s</p>"
|
||||
"<p>Events: %s</p>"
|
||||
"<p>Click Submit to create the app on GitHub. GitHub will "
|
||||
"redirect back here to finish setup.</p>"
|
||||
"<form action=\"%s\" method=\"post\">"
|
||||
"<input type=\"hidden\" name=\"manifest\" id=\"manifest\">"
|
||||
"<input type=\"submit\" value=\"Create GitHub App\">"
|
||||
"</form>"
|
||||
"<script>document.getElementById(\"manifest\").value = "
|
||||
"JSON.stringify(%s);</script>"
|
||||
"</body></html>"
|
||||
% (html.escape(manifest["name"]),
|
||||
html.escape(manifest["hook_attributes"]["url"]),
|
||||
html.escape(perms), html.escape(events),
|
||||
html.escape(action), manifest_json))
|
||||
self._send_html(200, page)
|
||||
|
||||
def _github_app_callback(self):
|
||||
"""建 App 回调:验 setup state -> 换 code -> 存配置。
|
||||
|
||||
state 一次性、1 小时过期;换 code 失败或响应缺字段时
|
||||
报错误页,不存任何东西。成功页只显示 App 身份(slug、
|
||||
id、主页)与安装链接,绝不显示 webhook_secret 与 pem
|
||||
(两者只在库里,server 进程内用)。
|
||||
P0 安全批后:回调要求签发人仍在 CODEX_GLOBAL_ADMINS
|
||||
里,否则 state 不消耗、直接报 invalid_state;覆盖全局
|
||||
配置时记审计。诚实边界:回调是浏览器无认证重定向,
|
||||
这里核对的是“签发人仍是管理员”,不是“回调者就是
|
||||
签发人”(见 SetupStateStore.consume_state 文档)。
|
||||
"""
|
||||
query = parse_qs(urlparse(self.path).query)
|
||||
code = (query.get("code") or [None])[0]
|
||||
state = (query.get("state") or [None])[0]
|
||||
if not code or not state:
|
||||
self._send_html(400, self._github_app_error_page(
|
||||
"missing_params", "code and state required"))
|
||||
return
|
||||
store = SetupStateStore(self.server.db_path)
|
||||
try:
|
||||
issuer = store.state_issuer(state)
|
||||
ok = store.consume_state(
|
||||
state, allowed_issuers=_global_admin_ids())
|
||||
finally:
|
||||
try:
|
||||
store.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if not ok:
|
||||
self._audit_admin(
|
||||
issuer or "unknown", "github_app_callback_denied",
|
||||
"state issuer not a global admin")
|
||||
self._send_html(400, self._github_app_error_page(
|
||||
"invalid_state", "setup state invalid or expired"))
|
||||
return
|
||||
http_client = getattr(self.server, "github_app_exchange_client",
|
||||
None)
|
||||
try:
|
||||
config = exchange_manifest_code(code, http_client=http_client)
|
||||
except AppError as e:
|
||||
self._send_html(400, self._github_app_error_page(
|
||||
e.reason or "exchange_failed", str(e)))
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
save_app_config(conn, config)
|
||||
finally:
|
||||
conn.close()
|
||||
self._audit_admin(
|
||||
issuer or "unknown", "github_app_config_replaced",
|
||||
"slug=%s app_id=%s" % (config.get("slug"),
|
||||
config.get("app_id")))
|
||||
slug = config.get("slug") or ""
|
||||
page = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<title>GitHub App created</title></head><body>"
|
||||
"<h1>GitHub App created</h1>"
|
||||
"<p>App: %s</p>"
|
||||
"<p>App ID: %s</p>"
|
||||
"<p>Install it on your repositories:</p>"
|
||||
"<p><a href=\"https://github.com/apps/%s/installations/new\">"
|
||||
"Install this GitHub App</a></p>"
|
||||
"<p>After installing, webhooks from the app are verified "
|
||||
"automatically. API actions (push, PR) still use the "
|
||||
"existing PAT until the App-token phase; you may remove "
|
||||
"the manual webhook once the app is installed.</p>"
|
||||
"</body></html>"
|
||||
% (html.escape(slug),
|
||||
html.escape(str(config.get("app_id"))),
|
||||
html.escape(slug)))
|
||||
self._send_html(200, page)
|
||||
|
||||
def _github_app_status(self):
|
||||
"""App 配置与安装状态:须持本站 key;绝不返回 secret/pem。"""
|
||||
user_id = self._authenticate()
|
||||
if user_id is None:
|
||||
self._send_json(401, {"error": "unauthorized"})
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
status = public_app_status(conn)
|
||||
status["installations"] = list_installations(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
self._send_json(200, status)
|
||||
@@ -0,0 +1,744 @@
|
||||
"""Handler mixin:ProjectMixin。
|
||||
|
||||
由 CloudControlPlaneHandler 拆出,方法体逐字搬移;
|
||||
self.* 调用(含其它 mixin 的方法与留守的 _send_json / _db 等)
|
||||
在混入后的同一类上解析,行为不变。本模块不 import cloud.server。
|
||||
"""
|
||||
|
||||
from cloud.auth import ROLES as _MEMBER_ROLES
|
||||
from cloud.db import PostgresIntegrityError as _PgIntegrityError
|
||||
from cloud.push import (
|
||||
delete_subscription as _push_delete,
|
||||
list_subscriptions as _push_list,
|
||||
)
|
||||
from cloud.peer_messages import row_get as _row_get
|
||||
from cloud.github_app import (
|
||||
list_installations,
|
||||
)
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
from cloud.repos import validate_repo_url
|
||||
from cloud.handler_support import (
|
||||
MAX_PROJECTS_PER_USER as MAX_PROJECTS_PER_USER,
|
||||
MAX_PROJECT_ID_LEN as MAX_PROJECT_ID_LEN,
|
||||
MAX_PROJECT_NAME_LEN as MAX_PROJECT_NAME_LEN,
|
||||
_auth_store as _auth_store,
|
||||
_environment_to_dict as _environment_to_dict,
|
||||
)
|
||||
|
||||
|
||||
class ProjectMixin:
|
||||
"""ProjectMixin 领域方法(见模块 docstring)。"""
|
||||
|
||||
# -- 环境 ----------------------------------------------------------
|
||||
def _fetch_environment(self, env_id):
|
||||
conn = self._db()
|
||||
try:
|
||||
return conn.execute(
|
||||
"SELECT * FROM environments WHERE id = ?", (env_id,)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _create_environment(self, user_id):
|
||||
"""建环境:只记录意图(status=creating),检出由 worker
|
||||
在下一轮准备(clone + checkout 分支),准备好后 status
|
||||
变 ready,失败变 failed 并带 error_text——创建本身是同步
|
||||
返回的记录,准备是异步的,与任务 queued->running 同款语义。
|
||||
同项目内未归档环境名唯一(409),避免选环境时分不清;
|
||||
归档过的名字可重用。项目没有 repo_url 时 400:环境就是
|
||||
仓库的基准检出,没有仓库就没有环境。"""
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
project_id = body.get("project_id")
|
||||
if not project_id or not isinstance(project_id, str):
|
||||
self._send_json(400, {"error": "project_id required"})
|
||||
return
|
||||
name = body.get("name")
|
||||
if not name or not isinstance(name, str) or not name.strip():
|
||||
self._send_json(400, {"error": "name required"})
|
||||
return
|
||||
name = name.strip()
|
||||
if len(name) > 100:
|
||||
self._send_json(400, {"error": "name too long"})
|
||||
return
|
||||
executor = body.get("executor")
|
||||
if executor is not None and executor not in ("local", "docker"):
|
||||
self._send_json(400, {
|
||||
"error": "executor must be local or docker"})
|
||||
return
|
||||
try:
|
||||
branch = _validate_env_branch(body.get("branch"))
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
err = self._check_project(user_id, project_id, "create_task")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
now = time.time()
|
||||
env_id = uuid.uuid4().hex
|
||||
conn = self._db()
|
||||
try:
|
||||
proj = conn.execute(
|
||||
"SELECT repo_url FROM projects WHERE id = ?",
|
||||
(project_id,)).fetchone()
|
||||
if proj is None:
|
||||
self._send_json(404, {"error": "project not found"})
|
||||
return
|
||||
repo_url = _row_get(proj, "repo_url")
|
||||
if not repo_url:
|
||||
self._send_json(400, {
|
||||
"error": "project has no repo_url, "
|
||||
"environment needs a repo"})
|
||||
return
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM environments WHERE project_id = ?"
|
||||
" AND name = ? AND status != 'archived'",
|
||||
(project_id, name)).fetchone()
|
||||
if existing:
|
||||
self._send_json(409, {
|
||||
"error": "environment name already exists"
|
||||
" in this project"})
|
||||
return
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO environments(id, project_id, name,"
|
||||
" branch, executor, status, error_text, repo_url,"
|
||||
" created_by, created_at, updated_at)"
|
||||
" VALUES(?, ?, ?, ?, ?, 'creating', NULL, ?, ?,"
|
||||
" ?, ?)",
|
||||
(env_id, project_id, name, branch, executor,
|
||||
repo_url, user_id, now, now))
|
||||
conn.commit()
|
||||
except (sqlite3.IntegrityError, _PgIntegrityError):
|
||||
self._send_json(409, {
|
||||
"error": "environment name already exists"
|
||||
" in this project"})
|
||||
return
|
||||
row = conn.execute(
|
||||
"SELECT * FROM environments WHERE id = ?", (env_id,)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id, "environment_created",
|
||||
env_id)
|
||||
self._send_json(201, _environment_to_dict(row))
|
||||
|
||||
def _list_environments(self, user_id, query):
|
||||
project_ids = query.get("project_id")
|
||||
project_id = project_ids[0] if project_ids else None
|
||||
if not project_id:
|
||||
self._send_json(400, {"error": "project_id required"})
|
||||
return
|
||||
err = self._check_project(user_id, project_id, "read")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
include_archived = query.get("include_archived") == ["1"]
|
||||
conn = self._db()
|
||||
try:
|
||||
if include_archived:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM environments WHERE project_id = ?"
|
||||
" ORDER BY created_at, id",
|
||||
(project_id,)).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM environments WHERE project_id = ?"
|
||||
" AND status != 'archived'"
|
||||
" ORDER BY created_at, id",
|
||||
(project_id,)).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
self._send_json(200, {
|
||||
"environments": [_environment_to_dict(r) for r in rows]})
|
||||
|
||||
def _get_environment(self, user_id, env_id):
|
||||
row = self._fetch_environment(env_id)
|
||||
if row is None:
|
||||
self._send_json(404, {"error": "environment not found"})
|
||||
return
|
||||
env = _environment_to_dict(row)
|
||||
err = self._check_project(user_id, env["project_id"], "read")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
self._send_json(200, env)
|
||||
|
||||
def _archive_environment(self, user_id, env_id):
|
||||
"""归档环境:已归档时幂等返回 200;有 queued/running
|
||||
任务正在用它时 409,绝不让进行中的任务失去基准检出。
|
||||
归档只改状态,不删检出目录(已完成任务的 worktree 独立,
|
||||
检出留着可查;磁盘清理另行手动做,不在归档里顺手删)。"""
|
||||
row = self._fetch_environment(env_id)
|
||||
if row is None:
|
||||
self._send_json(404, {"error": "environment not found"})
|
||||
return
|
||||
env = _environment_to_dict(row)
|
||||
err = self._check_project(
|
||||
user_id, env["project_id"], "create_task")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
if env["status"] == "archived":
|
||||
self._send_json(200, env)
|
||||
return
|
||||
now = time.time()
|
||||
conn = self._db()
|
||||
try:
|
||||
using = conn.execute(
|
||||
"SELECT id FROM tasks WHERE environment_id = ?"
|
||||
" AND status IN ('queued', 'running') LIMIT 1",
|
||||
(env_id,)).fetchone()
|
||||
if using:
|
||||
self._send_json(409, {
|
||||
"error": "environment is in use by a queued or"
|
||||
" running task"})
|
||||
return
|
||||
conn.execute(
|
||||
"UPDATE environments SET status = 'archived',"
|
||||
" updated_at = ? WHERE id = ?",
|
||||
(now, env_id))
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM environments WHERE id = ?", (env_id,)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, env["project_id"],
|
||||
"environment_archived", env_id)
|
||||
self._send_json(200, _environment_to_dict(row))
|
||||
|
||||
# -- 项目 ----------------------------------------------------------
|
||||
def _create_project(self, user_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
name = body.get("name")
|
||||
if not name or not isinstance(name, str):
|
||||
self._send_json(400, {"error": "name required"})
|
||||
return
|
||||
if len(name) > MAX_PROJECT_NAME_LEN:
|
||||
self._send_json(400, {
|
||||
"error": "project name too long: max %d characters"
|
||||
% MAX_PROJECT_NAME_LEN})
|
||||
return
|
||||
project_id = body.get("id") or uuid.uuid4().hex
|
||||
if not isinstance(project_id, str):
|
||||
self._send_json(400, {"error": "id must be a string"})
|
||||
return
|
||||
if len(project_id) > MAX_PROJECT_ID_LEN:
|
||||
self._send_json(400, {
|
||||
"error": "project id too long: max %d characters"
|
||||
% MAX_PROJECT_ID_LEN})
|
||||
return
|
||||
# 单用户项目数上限:按 projects.owner_id 计(成员身份加入
|
||||
# 的项目不计入自己的上限,避免“被拉进很多项目就建不了
|
||||
# 项目”);超限 400 且不建项目。
|
||||
_count_conn = self._db()
|
||||
try:
|
||||
_owned = _count_conn.execute(
|
||||
"SELECT COUNT(*) FROM projects WHERE owner_id = ?",
|
||||
(user_id,)).fetchone()[0]
|
||||
finally:
|
||||
_count_conn.close()
|
||||
if _owned >= MAX_PROJECTS_PER_USER:
|
||||
self._send_json(400, {
|
||||
"error": "project limit exceeded: max %d projects"
|
||||
" per user" % MAX_PROJECTS_PER_USER})
|
||||
return
|
||||
# repo_url 可选:不传时为 None(老行为不变);传了则与 worker
|
||||
# 共用 cloud.repos.validate_repo_url 的同一套校验,不合法时
|
||||
# 400 且不建项目,绝不把带凭证的 URL 存进库。
|
||||
repo_url = body.get("repo_url")
|
||||
if repo_url is not None:
|
||||
try:
|
||||
repo_url = validate_repo_url(repo_url)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
# auto_pr 可选,默认 0(关闭):只接受 0/1(布尔 True/False
|
||||
# 按 1/0 计),其它值 400 且不建项目。本轮没有现成的
|
||||
# PATCH /projects/{id} 路由,auto_pr 只能在创建时指定,
|
||||
# 建后改它需直接改库或重建项目(见 README)。
|
||||
auto_pr_raw = body.get("auto_pr", 0)
|
||||
if isinstance(auto_pr_raw, bool):
|
||||
auto_pr = 1 if auto_pr_raw else 0
|
||||
elif isinstance(auto_pr_raw, int) and auto_pr_raw in (0, 1):
|
||||
auto_pr = auto_pr_raw
|
||||
else:
|
||||
self._send_json(400, {"error": "auto_pr must be 0 or 1"})
|
||||
return
|
||||
now = time.time()
|
||||
conn = self._db()
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM projects WHERE id = ?", (project_id,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
self._send_json(409, {"error": "project exists"})
|
||||
return
|
||||
conn.execute(
|
||||
"INSERT INTO projects(id, name, owner_id, created_at,"
|
||||
" repo_url, auto_pr) VALUES(?, ?, ?, ?, ?, ?)",
|
||||
(project_id, name, user_id, now, repo_url, auto_pr),
|
||||
)
|
||||
conn.commit()
|
||||
project = {
|
||||
"id": project_id,
|
||||
"name": name,
|
||||
"owner_id": user_id,
|
||||
"created_at": now,
|
||||
"repo_url": repo_url,
|
||||
"auto_pr": auto_pr,
|
||||
}
|
||||
except (sqlite3.IntegrityError, _PgIntegrityError):
|
||||
self._send_json(409, {"error": "project exists"})
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
# AuthStore.create_project 只写成员表(owner 角色),项目记录
|
||||
# 本身在上面的 projects 表;两者配合才算一个完整项目。
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.create_project(project_id, user_id)
|
||||
store.audit(user_id, project_id, "create_project", name)
|
||||
self._send_json(201, project)
|
||||
|
||||
def _update_project(self, user_id, project_id):
|
||||
"""PATCH /projects/{id}:项目 owner 可改 repo_url(建后补仓
|
||||
库,解“建项目时没填仓库、之后建环境一直 400”的死结)。
|
||||
repo_url 缺 key 时 400;null 或空串表示清空;字符串走与
|
||||
创建时同一套 validate_repo_url 校验(带凭证的 URL 拒绝)。
|
||||
只改 projects 行,不动已建环境的快照 repo_url——已建环境
|
||||
仍用建环境时的仓库,新环境才用新值。改完记 update_project
|
||||
审计。"""
|
||||
err = self._check_project(user_id, project_id, "manage_project")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
body, jerr = self._read_json()
|
||||
if jerr:
|
||||
self._send_json(400, {"error": jerr})
|
||||
return
|
||||
if "repo_url" not in body:
|
||||
self._send_json(400, {"error": "repo_url required"})
|
||||
return
|
||||
repo_url = body.get("repo_url")
|
||||
if repo_url is not None and repo_url != "":
|
||||
if not isinstance(repo_url, str):
|
||||
self._send_json(400, {"error": "repo_url must be a string"})
|
||||
return
|
||||
try:
|
||||
repo_url = validate_repo_url(repo_url)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
else:
|
||||
repo_url = None
|
||||
conn = self._db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT id, name, owner_id, created_at, repo_url, auto_pr"
|
||||
" FROM projects WHERE id = ?",
|
||||
(project_id,)).fetchone()
|
||||
if row is None:
|
||||
self._send_json(404, {"error": "project not found"})
|
||||
return
|
||||
conn.execute(
|
||||
"UPDATE projects SET repo_url = ? WHERE id = ?",
|
||||
(repo_url, project_id))
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT id, name, owner_id, created_at, repo_url, auto_pr"
|
||||
" FROM projects WHERE id = ?",
|
||||
(project_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id, "update_project",
|
||||
repo_url or "(cleared)")
|
||||
self._send_json(200, {"project": _project_to_dict(row)})
|
||||
|
||||
def _github_repos_list(self, user_id):
|
||||
"""GET /github/repos:只返回调用者成员项目对应的仓库。
|
||||
|
||||
P1 收紧前返回全部安装记录(任一已认证用户可看到全部
|
||||
安装仓库名,属跨租户信息泄露)。现按 project_members
|
||||
过滤:只保留调用者是成员的项目的 repo_url 对应的仓库
|
||||
(clone_url 归一化后比对:去 .git 后缀、比对小写);
|
||||
调用者无成员项目或项目未填 repo_url 时返回空列表。
|
||||
手填 URL 的能力不变——没进 App 安装的公开仓库仍可手动填。"""
|
||||
conn = self._db()
|
||||
try:
|
||||
rows = list_installations(conn)
|
||||
member_ids = {
|
||||
row["project_id"]
|
||||
for row in conn.execute(
|
||||
"SELECT project_id FROM project_members"
|
||||
" WHERE user_id = ?",
|
||||
(user_id,)).fetchall()
|
||||
}
|
||||
allowed = set()
|
||||
for row in conn.execute(
|
||||
"SELECT id, repo_url FROM projects").fetchall():
|
||||
if row["id"] not in member_ids or not row["repo_url"]:
|
||||
continue
|
||||
url = str(row["repo_url"]).strip().lower()
|
||||
if url.endswith(".git"):
|
||||
url = url[:-4]
|
||||
allowed.add(url)
|
||||
finally:
|
||||
conn.close()
|
||||
repos = []
|
||||
for r in rows:
|
||||
clone_url = "https://github.com/%s.git" % r["repo_full_name"]
|
||||
normalized = clone_url.lower()
|
||||
if normalized.endswith(".git"):
|
||||
normalized = normalized[:-4]
|
||||
if normalized not in allowed:
|
||||
continue
|
||||
repos.append({
|
||||
"installation_id": r["installation_id"],
|
||||
"account_login": r["account_login"],
|
||||
"repo_full_name": r["repo_full_name"],
|
||||
"clone_url": clone_url,
|
||||
})
|
||||
self._send_json(200, {"repos": repos})
|
||||
|
||||
def _list_projects(self, user_id):
|
||||
conn = self._db()
|
||||
try:
|
||||
member_rows = conn.execute(
|
||||
"SELECT project_id, role FROM project_members"
|
||||
" WHERE user_id = ?",
|
||||
(user_id,)).fetchall()
|
||||
member_ids = {row["project_id"] for row in member_rows}
|
||||
roles = {row["project_id"]: row["role"]
|
||||
for row in member_rows}
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, owner_id, created_at, repo_url, auto_pr"
|
||||
" FROM projects ORDER BY created_at, id"
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
projects = []
|
||||
for r in rows:
|
||||
if r["id"] not in member_ids:
|
||||
continue
|
||||
# 附加字段 my_role(只增字段,不改已有成功语义):
|
||||
# 控制台据此按角色禁用重试/合并/提 PR 等按钮;服务端
|
||||
# 权限仍以每次请求的 check_permission 为准,前端禁用
|
||||
# 只是防误点,不是鉴权。
|
||||
item = _project_to_dict(r)
|
||||
item["my_role"] = roles.get(r["id"])
|
||||
projects.append(item)
|
||||
self._send_json(200, {"projects": projects})
|
||||
|
||||
# -- 项目成员:邀请 / 列表 / 改角色 -------------------------------
|
||||
def _can_manage_members(self, user_id, project_id):
|
||||
"""成员管理权:项目 owner 或全局管理员。
|
||||
|
||||
全局管理员(CODEX_GLOBAL_ADMINS)不一定是项目成员,
|
||||
故不能只用项目内 manage_project 判定;项目 owner 判定
|
||||
复用 check_permission 的 manage_project(仅 owner)。
|
||||
"""
|
||||
if self._is_global_admin(user_id):
|
||||
return True
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
return store.check_permission(
|
||||
user_id, project_id, "manage_project")
|
||||
|
||||
def _set_member_row(self, store, project_id, actor_id, target_id,
|
||||
role):
|
||||
"""写成员行并记审计:owner 走 AuthStore.set_member(内含
|
||||
owner 校验与审计);非成员的全局管理员走直接写 + 同格式
|
||||
审计(set_member 的 owner 校验会挡住全局管理员,故分流)。
|
||||
"""
|
||||
if store.check_permission(actor_id, project_id,
|
||||
"manage_project"):
|
||||
store.set_member(project_id, actor_id, target_id, role)
|
||||
return
|
||||
store.conn.execute(
|
||||
"INSERT OR REPLACE INTO project_members"
|
||||
"(project_id, user_id, role) VALUES(?, ?, ?)",
|
||||
(project_id, target_id, role))
|
||||
store.conn.commit()
|
||||
store.audit(actor_id, project_id, "set_member",
|
||||
"%s:%s" % (target_id, role))
|
||||
|
||||
def _project_members_list(self, user_id, project_id):
|
||||
"""GET /projects/{id}/members:项目成员(read)或全局
|
||||
管理员可读。只返回 user_id / username / role,绝不返回
|
||||
key 哈希等认证字段。"""
|
||||
if not self._project_exists(project_id):
|
||||
self._send_json(404, {"error": "project not found"})
|
||||
return
|
||||
if not self._is_global_admin(user_id):
|
||||
err = self._check_project(user_id, project_id, "read")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT m.user_id, m.role, u.username FROM"
|
||||
" project_members m LEFT JOIN users u"
|
||||
" ON u.id = m.user_id WHERE m.project_id = ?"
|
||||
" ORDER BY u.username, m.user_id",
|
||||
(project_id,)).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
self._send_json(200, {"project_id": project_id, "members": [
|
||||
{"user_id": r["user_id"], "username": r["username"],
|
||||
"role": r["role"]} for r in rows]})
|
||||
|
||||
def _resolve_invite_target(self, body):
|
||||
"""邀请目标解析:优先 user_id,其次 username 查 id;
|
||||
返回 (target_id, error_status, error)。"""
|
||||
target = body.get("user_id")
|
||||
username = body.get("username")
|
||||
if target is not None and not isinstance(target, str):
|
||||
return None, 400, "user_id must be a string"
|
||||
if username is not None and not isinstance(username, str):
|
||||
return None, 400, "username must be a string"
|
||||
if not target and not username:
|
||||
return None, 400, "user_id or username required"
|
||||
conn = self._db()
|
||||
try:
|
||||
if target:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM users WHERE id = ?",
|
||||
(target,)).fetchone()
|
||||
if row is None:
|
||||
return None, 404, "user not found"
|
||||
return row["id"], None, None
|
||||
row = conn.execute(
|
||||
"SELECT id FROM users WHERE username = ?",
|
||||
(username,)).fetchone()
|
||||
if row is None:
|
||||
return None, 404, "user not found"
|
||||
return row["id"], None, None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _project_member_invite(self, user_id, project_id):
|
||||
"""POST /projects/{id}/members:限项目 owner 或全局管理
|
||||
员。已是成员时 409(改角色走 PATCH,不用邀请覆盖);
|
||||
成功记 set_member 审计。role 缺省 contributor。"""
|
||||
if not self._project_exists(project_id):
|
||||
self._send_json(404, {"error": "project not found"})
|
||||
return
|
||||
if not self._can_manage_members(user_id, project_id):
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
body, jerr = self._read_json()
|
||||
if jerr:
|
||||
self._send_json(400, {"error": jerr})
|
||||
return
|
||||
role = body.get("role") or "contributor"
|
||||
if role not in _MEMBER_ROLES:
|
||||
self._send_json(400, {"error": "unknown role"})
|
||||
return
|
||||
target, estatus, eerr = self._resolve_invite_target(body)
|
||||
if eerr:
|
||||
self._send_json(estatus, {"error": eerr})
|
||||
return
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
existing = store.conn.execute(
|
||||
"SELECT role FROM project_members"
|
||||
" WHERE project_id = ? AND user_id = ?",
|
||||
(project_id, target)).fetchone()
|
||||
if existing is not None:
|
||||
self._send_json(409, {"error": "already_member"})
|
||||
return
|
||||
self._set_member_row(store, project_id, user_id,
|
||||
target, role)
|
||||
self._send_json(201, {"project_id": project_id,
|
||||
"user_id": target, "role": role})
|
||||
|
||||
def _project_member_patch(self, user_id, project_id, target):
|
||||
"""PATCH /projects/{id}/members/{user_id}:限项目 owner
|
||||
或全局管理员。唯一 owner 降为非 owner 时 409(无论降
|
||||
自己还是降他人——否则项目将无 owner 可管);目标非成员
|
||||
时 404。"""
|
||||
if not self._project_exists(project_id):
|
||||
self._send_json(404, {"error": "project not found"})
|
||||
return
|
||||
if not self._can_manage_members(user_id, project_id):
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
body, jerr = self._read_json()
|
||||
if jerr:
|
||||
self._send_json(400, {"error": jerr})
|
||||
return
|
||||
role = body.get("role")
|
||||
if role not in _MEMBER_ROLES:
|
||||
self._send_json(400, {"error": "unknown role"})
|
||||
return
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
cur = store.conn.execute(
|
||||
"SELECT role FROM project_members"
|
||||
" WHERE project_id = ? AND user_id = ?",
|
||||
(project_id, target)).fetchone()
|
||||
if cur is None:
|
||||
self._send_json(404, {"error": "member not found"})
|
||||
return
|
||||
if cur[0] == "owner" and role != "owner":
|
||||
owners = store.conn.execute(
|
||||
"SELECT COUNT(*) FROM project_members"
|
||||
" WHERE project_id = ? AND role = 'owner'",
|
||||
(project_id,)).fetchone()[0]
|
||||
if owners <= 1:
|
||||
self._send_json(
|
||||
409,
|
||||
{"error": "cannot demote the only owner"})
|
||||
return
|
||||
self._set_member_row(store, project_id, user_id,
|
||||
target, role)
|
||||
self._send_json(200, {"project_id": project_id,
|
||||
"user_id": target, "role": role})
|
||||
|
||||
def _project_member_delete(self, user_id, project_id, target):
|
||||
"""DELETE /projects/{id}/members/{user_id}:限项目 owner
|
||||
或全局管理员。删唯一 owner 时 409(否则项目将无 owner
|
||||
可管);目标非成员时 404。成功记 remove_member 审计。"""
|
||||
if not self._project_exists(project_id):
|
||||
self._send_json(404, {"error": "project not found"})
|
||||
return
|
||||
if not self._can_manage_members(user_id, project_id):
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
cur = store.conn.execute(
|
||||
"SELECT role FROM project_members"
|
||||
" WHERE project_id = ? AND user_id = ?",
|
||||
(project_id, target)).fetchone()
|
||||
if cur is None:
|
||||
self._send_json(404, {"error": "member not found"})
|
||||
return
|
||||
if cur[0] == "owner":
|
||||
owners = store.conn.execute(
|
||||
"SELECT COUNT(*) FROM project_members"
|
||||
" WHERE project_id = ? AND role = 'owner'",
|
||||
(project_id,)).fetchone()[0]
|
||||
if owners <= 1:
|
||||
self._send_json(
|
||||
409,
|
||||
{"error": "cannot remove the only owner"})
|
||||
return
|
||||
self._remove_member_row(store, project_id, user_id, target)
|
||||
self._send_json(200, {"project_id": project_id,
|
||||
"user_id": target})
|
||||
|
||||
def _remove_member_row(self, store, project_id, actor_id, target_id):
|
||||
"""删成员行并记审计:owner 走 AuthStore.remove_member
|
||||
(内含 owner 校验与审计);非成员的全局管理员走直接删
|
||||
+ 同格式审计(remove_member 的 owner 校验会挡住全局
|
||||
管理员,故分流,与 _set_member_row 同款)。"""
|
||||
if store.check_permission(actor_id, project_id,
|
||||
"manage_project"):
|
||||
store.remove_member(project_id, actor_id, target_id)
|
||||
return
|
||||
store.conn.execute(
|
||||
"DELETE FROM project_members"
|
||||
" WHERE project_id = ? AND user_id = ?",
|
||||
(project_id, target_id))
|
||||
store.conn.commit()
|
||||
store.audit(actor_id, project_id, "remove_member", target_id)
|
||||
|
||||
# -- 项目子资源:记忆 / 知识库 / 成本 -------------------------------
|
||||
def _project_exists(self, project_id):
|
||||
conn = self._db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM projects WHERE id = ?", (project_id,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _check_project(self, user_id, project_id, action):
|
||||
"""返回 None 表示通过;否则返回 (status, error) 先行响应。"""
|
||||
if not self._project_exists(project_id):
|
||||
return 404, "project not found"
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
if not store.check_permission(user_id, project_id, action):
|
||||
return 403, "forbidden"
|
||||
return None
|
||||
|
||||
def _project_push_subscription_delete(self, user_id, method, project_id, parts):
|
||||
# 从 _dispatch_project 平移的 push 订阅删除分支:
|
||||
# 语句原样平移,仅缩进减一格;每条路径都已发响应。
|
||||
if method != "DELETE":
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
err = self._check_project(user_id, project_id, "create_task")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
subs = _push_list(self.server.db_path, project_id)
|
||||
mine = [s for s in subs if s["id"] == parts[3]]
|
||||
if not mine:
|
||||
self._send_json(404, {"error": "subscription not found"})
|
||||
return
|
||||
# 只能删自己的订阅,项目 owner 可删任意(owner 角色检查)。
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
is_owner = auth.check_permission(
|
||||
user_id, project_id, "manage_project")
|
||||
if mine[0]["user_id"] != user_id and not is_owner:
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
deleted = _push_delete(self.server.db_path,
|
||||
subscription_id=parts[3])
|
||||
if deleted:
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(
|
||||
user_id, project_id, "push_subscription_deleted",
|
||||
"subscription_id=%s" % parts[3])
|
||||
self._send_json(200, {"deleted": bool(deleted)})
|
||||
return
|
||||
|
||||
|
||||
def _project_to_dict(row):
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"owner_id": row["owner_id"],
|
||||
"created_at": row["created_at"],
|
||||
# 老库行缺 repo_url 列时 _row_get 给 None,不炸(列本身由
|
||||
# migrate_projects_table 在 init_db 时补,老行读到也是 None)。
|
||||
"repo_url": _row_get(row, "repo_url"),
|
||||
# auto_pr 默认 0(关闭):老行缺列时 None 按 0 计,绝不因
|
||||
# 迁移就把老项目默认改成自动推。
|
||||
"auto_pr": _row_get(row, "auto_pr") or 0,
|
||||
}
|
||||
|
||||
_ENV_BRANCH_RE = re.compile(r"^[A-Za-z0-9_.\-/]+$")
|
||||
|
||||
|
||||
def _validate_env_branch(branch):
|
||||
"""环境分支名校验:空/None 表示默认分支;非空时只允许
|
||||
字母数字与 _ . - /,且不能以 - 开头、不能含 ..、不能以
|
||||
.lock 结尾、长度不超过 200——git ref 名的保守子集,防
|
||||
分支名被拼进 shell 或越出目录。返回规范化后的分支或 None,
|
||||
非法时抛 ValueError(调用方转 400)。"""
|
||||
if branch is None:
|
||||
return None
|
||||
if not isinstance(branch, str):
|
||||
raise ValueError("branch must be a string")
|
||||
branch = branch.strip()
|
||||
if not branch:
|
||||
return None
|
||||
if len(branch) > 200 or not _ENV_BRANCH_RE.match(branch) \
|
||||
or branch.startswith("-") or ".." in branch \
|
||||
or branch.endswith(".lock"):
|
||||
raise ValueError("branch is not a valid git branch name")
|
||||
return branch
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Handler 共享 helper:被多个 handler 分组与留守代码共用。
|
||||
|
||||
由 cloud/server.py 拆出,行为与原来逐字一致;server.py 以
|
||||
重导出保留旧导入路径(见 server.py 的 handler_support 导入块)。
|
||||
本模块不 import cloud.server(避免循环),不直连 sqlite3,
|
||||
不 import psycopg;存储一律走 cloud.db / 各 Store。
|
||||
"""
|
||||
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.coordinator import parse_parts as _coord_parse_parts
|
||||
from cloud.peer_messages import ensure_followups_table as _peer_ensure_followups
|
||||
from cloud.quota import default_quota_usd_micros as _quota_default
|
||||
from cloud.peer_messages import row_get as _row_get
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
|
||||
def _env_int(name, default):
|
||||
try:
|
||||
value = int(os.environ.get(name, "") or default)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return value if value > 0 else default
|
||||
|
||||
|
||||
def _ensure_followups_table(conn):
|
||||
"""幂等建后续消息表:委托给 peer_messages(两边同一份 DDL,
|
||||
server init_db、各 followup 接口与 worker 投递路径共用)。
|
||||
老库第一次跑到这里时自动补表补列,不丢数据。DOUBLE
|
||||
PRECISION 在 SQLite 与 Postgres 下都可用(SQLite 按 NUMERIC
|
||||
亲和处理)。"""
|
||||
_peer_ensure_followups(conn)
|
||||
|
||||
|
||||
def _ensure_quotas_table(conn):
|
||||
"""幂等建配额表:server init_db 与配额查询都可调它,老库
|
||||
第一次跑到这里时自动补表,不丢数据。quota_usd_micros 为
|
||||
NULL 表示不限额(与 0 区分:0 是一分钱也不能花);金额
|
||||
单位与 cost_records 一致,都是 USD 微单位(1 美元 =
|
||||
1_000_000),避免浮点。"""
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS user_quotas("
|
||||
"user_id TEXT PRIMARY KEY, "
|
||||
"quota_usd_micros BIGINT, "
|
||||
"updated_at DOUBLE PRECISION)")
|
||||
|
||||
|
||||
def _default_quota_usd_micros():
|
||||
"""OAuth 新用户建号时写入的默认配额(微美元)。
|
||||
|
||||
无配额行 = 不限额,所以 OAuth 自动建号过去等于无限额入驻。
|
||||
新号现在建号即写一行默认配额(老用户无配额行不受影响,
|
||||
行为不变)。环境变量 CLOUD_DEFAULT_QUOTA_USD_MICROS 可
|
||||
覆盖,默认 10_000_000($10/月);非法值按默认计。
|
||||
解析收敛到 cloud/quota.py(别名保留)。
|
||||
"""
|
||||
return _quota_default()
|
||||
|
||||
|
||||
def _apply_default_quota(conn, user_id):
|
||||
"""给用户写默认配额行(已存在则不动,幂等)。
|
||||
|
||||
INSERT OR IGNORE 保证重复回调不覆盖管理员后来改过的额度。
|
||||
返回 True 表示本次写了新行(调用方据此记 quota_default
|
||||
审计)。P0 安全批后:OAuth 新建、绑定、已存在用户登录
|
||||
三条分支都调它补行,不再只给新建用户补。
|
||||
"""
|
||||
_ensure_quotas_table(conn)
|
||||
cur = conn.execute(
|
||||
"INSERT OR IGNORE INTO user_quotas(user_id, quota_usd_micros,"
|
||||
" updated_at) VALUES(?, ?, ?)",
|
||||
(user_id, _default_quota_usd_micros(), time.time()))
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return cur.rowcount == 1
|
||||
|
||||
|
||||
def _global_admin_ids():
|
||||
"""真全局管理员清单:环境变量 CODEX_GLOBAL_ADMINS。
|
||||
|
||||
逗号分隔的用户 id 列表,空白项忽略。未配置或为空时返回
|
||||
空集——此时 admin 端点一律 fail-closed,绝不回落到项目
|
||||
角色推导(P0 安全批前“任一项目 maintainer 即全局管理”
|
||||
的推导已被废掉,见 _is_global_admin)。
|
||||
"""
|
||||
raw = os.environ.get("CODEX_GLOBAL_ADMINS") or ""
|
||||
return {part.strip() for part in raw.split(",") if part.strip()}
|
||||
|
||||
|
||||
def _environment_to_dict(row):
|
||||
return {
|
||||
"id": row["id"],
|
||||
"project_id": row["project_id"],
|
||||
"name": row["name"],
|
||||
"branch": _row_get(row, "branch"),
|
||||
"executor": _row_get(row, "executor"),
|
||||
"status": row["status"],
|
||||
"error_text": _row_get(row, "error_text"),
|
||||
"repo_url": _row_get(row, "repo_url"),
|
||||
"created_by": row["created_by"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _auth_store(db_path):
|
||||
"""每请求一个 AuthStore 实例(自带新连接),用完即关闭。"""
|
||||
store = AuthStore(db_path)
|
||||
try:
|
||||
yield store
|
||||
finally:
|
||||
try:
|
||||
store.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _task_to_dict(row, attachments=None, total_tokens=None):
|
||||
depends_on = row["depends_on"]
|
||||
try:
|
||||
depends_on = json.loads(depends_on) if depends_on else []
|
||||
except (TypeError, ValueError):
|
||||
depends_on = []
|
||||
if not isinstance(depends_on, list):
|
||||
depends_on = [depends_on]
|
||||
return {
|
||||
"id": row["id"],
|
||||
"project_id": row["project_id"],
|
||||
"prompt": row["prompt"],
|
||||
"status": row["status"],
|
||||
"idempotency_key": row["idempotency_key"],
|
||||
"depends_on": depends_on,
|
||||
"priority": row["priority"],
|
||||
"created_by": row["created_by"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"last_heartbeat": row["last_heartbeat"],
|
||||
"result_text": _row_get(row, "result_text"),
|
||||
"result_full_available": bool(_row_get(row, "result_text_full")),
|
||||
"error_text": _row_get(row, "error_text"),
|
||||
"actual_model": _row_get(row, "actual_model"),
|
||||
"started_at": _row_get(row, "started_at"),
|
||||
"finished_at": _row_get(row, "finished_at"),
|
||||
"child_pid": _row_get(row, "child_pid"),
|
||||
"executor": _row_get(row, "executor"),
|
||||
"pr_url": _row_get(row, "pr_url"),
|
||||
"pr_number": _row_get(row, "pr_number"),
|
||||
"goal": _row_get(row, "goal"),
|
||||
"verify_command": _row_get(row, "verify_command"),
|
||||
"goal_max_rounds": _row_get(row, "goal_max_rounds"),
|
||||
"goal_mode": _row_get(row, "goal_mode") or 0,
|
||||
"attachments": list(attachments or []),
|
||||
"resume_message": _row_get(row, "resume_message"),
|
||||
"worktree_host": _row_get(row, "worktree_host"),
|
||||
"claimed_by_worker": _row_get(row, "claimed_by_worker"),
|
||||
"model": _row_get(row, "model"),
|
||||
"effort": _row_get(row, "effort"),
|
||||
"child_model": _row_get(row, "child_model"),
|
||||
"child_effort": _row_get(row, "child_effort"),
|
||||
"approval": _row_get(row, "approval"),
|
||||
"plan": _row_get(row, "plan") or 0,
|
||||
"forked_from": _row_get(row, "forked_from"),
|
||||
"environment_id": _row_get(row, "environment_id"),
|
||||
"turn_id": _row_get(row, "turn_id"),
|
||||
"root_turn_id": _row_get(row, "root_turn_id"),
|
||||
"coordinator_parts": (
|
||||
_coord_parse_parts(_row_get(row, "coordinator_parts"))
|
||||
if _row_get(row, "coordinator_parts") else None),
|
||||
"parent_task_id": _row_get(row, "parent_task_id"),
|
||||
"coordinator_part": _row_get(row, "coordinator_part"),
|
||||
"goal_resume_pending": _row_get(row, "goal_resume_pending") or 0,
|
||||
"paused_by_user": _row_get(row, "paused_by_user") or 0,
|
||||
"interrupted_reason": _row_get(row, "interrupted_reason"),
|
||||
"auto_resume_count": _row_get(row, "auto_resume_count") or 0,
|
||||
"no_time_limit": _row_get(row, "no_time_limit") or 0,
|
||||
# 列表接口由 _task_tokens_map 一次聚合传入;其它调用方
|
||||
# 不传时为 None(前端显示占位),绝不把“未算”写成 0。
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
|
||||
|
||||
MAX_PROJECTS_PER_USER = _env_int("CODEX_MAX_PROJECTS_PER_USER", 50)
|
||||
MAX_PROJECT_ID_LEN = _env_int("CODEX_MAX_PROJECT_ID_LEN", 64)
|
||||
MAX_PROJECT_NAME_LEN = _env_int("CODEX_MAX_PROJECT_NAME_LEN", 100)
|
||||
+22
-3105
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,162 @@
|
||||
"""Tests for the v2 handler mixin split (integration base 02835f6).
|
||||
|
||||
Pins the split contract so a future violation fails CI:
|
||||
- the 94 moved methods still resolve on CloudControlPlaneHandler and
|
||||
come from their mixin module (behaviour unchanged, only the defining
|
||||
module moved);
|
||||
- mixin/support modules never import cloud.server (no cycle), never
|
||||
call sqlite3.connect and never import psycopg (narrow AST scan, same
|
||||
as #238 arch_conformance.scan_imports);
|
||||
- server.py re-exports the moved shared helpers as identical objects,
|
||||
so `from cloud.server import _task_to_dict` keeps working;
|
||||
- handler method accounting: 238 total, 144 left in server.py itself,
|
||||
94 in mixins, no name defined twice.
|
||||
|
||||
Only the standard library; no real model, no network, no secrets.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from cloud.arch_conformance import scan_imports # noqa: E402
|
||||
from cloud.handler_support import ( # noqa: E402
|
||||
_apply_default_quota, _default_quota_usd_micros, _task_to_dict)
|
||||
from cloud.server import CloudControlPlaneHandler # noqa: E402
|
||||
from cloud import server as _server_module # noqa: E402
|
||||
|
||||
MOVED = {
|
||||
"cloud.handler_oauth": [
|
||||
"_oauth_provider", "_oauth_login", "_oauth_clients",
|
||||
"_oauth_finish", "_oauth_callback", "_oauth_error_page",
|
||||
"_oauth_success_page", "_oauth_bind_success_page",
|
||||
"_oauth_callback_get", "_oauth_bind_route",
|
||||
"_oauth_bind_initiate", "_oauth_bindings_get",
|
||||
"_oauth_bindings_delete", "_github_app_public_base",
|
||||
"_github_app_error_page", "_github_app_setup_state",
|
||||
"_github_app_setup_page", "_github_app_callback",
|
||||
"_github_app_status"],
|
||||
"cloud.handler_identity": [
|
||||
"_memory_create", "_memory_list", "_memory_supersede",
|
||||
"_identity_store", "_identity_close", "_identity_profile_get",
|
||||
"_identity_profile_upsert", "_identity_user_memories_list",
|
||||
"_identity_user_memory_create", "_identity_user_memory_supersede",
|
||||
"_identity_user_memory_delete", "_identity_context",
|
||||
"_auto_memory_store", "_auto_memory_close",
|
||||
"_auto_memory_settings_get", "_auto_memory_settings_upsert",
|
||||
"_auto_memory_log", "_auto_memory_run", "_task_auto_memory_flags",
|
||||
"_project_identity_memory_create",
|
||||
"_project_identity_memories_list",
|
||||
"_project_identity_memory_supersede",
|
||||
"_project_identity_memory_delete",
|
||||
"_kb_index", "_kb_search", "_project_cost", "_task_cost",
|
||||
"_relay_models", "_models_overview"],
|
||||
"cloud.handler_project": [
|
||||
"_fetch_environment", "_create_environment", "_list_environments",
|
||||
"_get_environment", "_archive_environment", "_create_project",
|
||||
"_update_project", "_github_repos_list", "_list_projects",
|
||||
"_can_manage_members", "_set_member_row", "_project_members_list",
|
||||
"_resolve_invite_target", "_project_member_invite",
|
||||
"_project_member_patch", "_project_member_delete",
|
||||
"_remove_member_row", "_project_exists", "_check_project",
|
||||
"_project_push_subscription_delete"],
|
||||
"cloud.handler_automation": [
|
||||
"_push_create_endpoint", "_push_dispatch_endpoint",
|
||||
"_automation_create", "_automation_list",
|
||||
"_todos_list", "_todos_create", "_todos_get", "_todos_update",
|
||||
"_todos_delete", "_automation_cancel", "_automation_get",
|
||||
"_automation_bind_get", "_automation_bind", "_automation_unbind",
|
||||
"_automation_heartbeat", "_automation_update", "_triage_inbox"],
|
||||
"cloud.handler_fullauto": [
|
||||
"_full_auto_status", "_full_auto_config_plan",
|
||||
"_full_auto_validate_action", "_full_auto_merge_gate",
|
||||
"_full_auto_ci_fix", "_full_auto_release_plan",
|
||||
"_full_auto_rollback_plan", "_full_auto_audit_list",
|
||||
"_full_auto_execute"],
|
||||
}
|
||||
|
||||
NEW_MODULES = ["cloud/handler_support.py", "cloud/handler_oauth.py",
|
||||
"cloud/handler_identity.py", "cloud/handler_project.py",
|
||||
"cloud/handler_automation.py", "cloud/handler_fullauto.py"]
|
||||
|
||||
|
||||
def _own_methods(cls):
|
||||
return {name for name, value in vars(cls).items()
|
||||
if callable(value)}
|
||||
|
||||
|
||||
class TestMovedMethodsResolve(unittest.TestCase):
|
||||
def test_all_moved_methods_resolve_from_handler(self):
|
||||
for module, names in MOVED.items():
|
||||
for name in names:
|
||||
func = getattr(CloudControlPlaneHandler, name, None)
|
||||
self.assertIsNotNone(
|
||||
func, "%s missing on handler" % name)
|
||||
self.assertEqual(
|
||||
func.__module__, module,
|
||||
"%s should be defined in %s" % (name, module))
|
||||
|
||||
def test_moved_count_is_94(self):
|
||||
total = sum(len(names) for names in MOVED.values())
|
||||
self.assertEqual(total, 94)
|
||||
|
||||
|
||||
class TestNoCyclesOrDrivers(unittest.TestCase):
|
||||
def test_new_modules_pass_layer_scan(self):
|
||||
repo_root = os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.abspath(__file__))))
|
||||
for rel in NEW_MODULES:
|
||||
with open(os.path.join(repo_root, rel),
|
||||
encoding="utf-8") as f:
|
||||
scanned = scan_imports(f.read())
|
||||
self.assertNotIn("server", scanned["cloud"],
|
||||
"%s must not import cloud.server" % rel)
|
||||
self.assertFalse(scanned["driver_connect"],
|
||||
"%s must not call sqlite3.connect" % rel)
|
||||
self.assertFalse(scanned["driver_import"],
|
||||
"%s must not import psycopg" % rel)
|
||||
|
||||
|
||||
class TestReexports(unittest.TestCase):
|
||||
def test_shared_helpers_are_identical_objects(self):
|
||||
self.assertIs(_server_module._task_to_dict, _task_to_dict)
|
||||
self.assertIs(_server_module._apply_default_quota,
|
||||
_apply_default_quota)
|
||||
self.assertIs(_server_module._default_quota_usd_micros,
|
||||
_default_quota_usd_micros)
|
||||
|
||||
def test_base_plumbing_stays_in_server(self):
|
||||
own = _own_methods(CloudControlPlaneHandler)
|
||||
for name in ["_send_json", "_send_html", "_read_body",
|
||||
"_read_json", "_db", "_authenticate", "do_GET",
|
||||
"do_POST", "_dispatch", "_dispatch_project",
|
||||
"_dispatch_task_route"]:
|
||||
self.assertIn(name, own, "%s must stay in server.py" % name)
|
||||
|
||||
|
||||
class TestMethodAccounting(unittest.TestCase):
|
||||
def test_total_238_no_duplicates(self):
|
||||
seen = {}
|
||||
dups = []
|
||||
classes = [CloudControlPlaneHandler]
|
||||
for base in CloudControlPlaneHandler.__mro__:
|
||||
if base.__module__.startswith("cloud.handler_"):
|
||||
classes.append(base)
|
||||
total = 0
|
||||
for cls in classes:
|
||||
for name in _own_methods(cls):
|
||||
total += 1
|
||||
if name in seen:
|
||||
dups.append((seen[name], cls.__name__, name))
|
||||
seen[name] = cls.__name__
|
||||
self.assertEqual(dups, [])
|
||||
self.assertEqual(total, 238)
|
||||
self.assertEqual(len(_own_methods(CloudControlPlaneHandler)),
|
||||
238 - 94)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -24,6 +24,11 @@ try:
|
||||
from cloud.push import create_subscription
|
||||
from cloud.server import create_server
|
||||
from cloud import server as server_mod
|
||||
# 拆分后补丁目标跟随方法定义模块:项目上限在 handler_project,
|
||||
# relay 模型查询在 handler_identity(方法查本模块全局变量,
|
||||
# 补丁 cloud.server 已不再生效,见 code-to10-v2-split 摘要)。
|
||||
from cloud import handler_identity as handler_identity_mod
|
||||
from cloud import handler_project as handler_project_mod
|
||||
from cloud.sub2api import Sub2apiError
|
||||
from cloud.sub2api import ensure_gateway_keys_table
|
||||
from cloud.sub2api import get_user_gateway_key
|
||||
@@ -36,6 +41,8 @@ except ImportError:
|
||||
from cloud.push import create_subscription
|
||||
from cloud.server import create_server
|
||||
from cloud import server as server_mod
|
||||
from cloud import handler_identity as handler_identity_mod
|
||||
from cloud import handler_project as handler_project_mod
|
||||
from cloud.sub2api import Sub2apiError
|
||||
from cloud.sub2api import ensure_gateway_keys_table
|
||||
from cloud.sub2api import get_user_gateway_key
|
||||
@@ -265,7 +272,7 @@ class ServerP1TestCase(unittest.TestCase):
|
||||
os.environ.pop("CLOUD_GATEWAY", None)
|
||||
os.environ.pop("CLOUD_BACKUP_ROOT", None)
|
||||
os.environ.pop("CLOUD_BACKUP_DIR", None)
|
||||
self._old_max_projects = server_mod.MAX_PROJECTS_PER_USER
|
||||
self._old_max_projects = handler_project_mod.MAX_PROJECTS_PER_USER
|
||||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
@@ -287,7 +294,7 @@ class ServerP1TestCase(unittest.TestCase):
|
||||
except Exception:
|
||||
pass
|
||||
self.tmp.cleanup()
|
||||
server_mod.MAX_PROJECTS_PER_USER = self._old_max_projects
|
||||
handler_project_mod.MAX_PROJECTS_PER_USER = self._old_max_projects
|
||||
if self._old_admins is None:
|
||||
os.environ.pop("CODEX_GLOBAL_ADMINS", None)
|
||||
else:
|
||||
@@ -304,11 +311,13 @@ class ServerP1TestCase(unittest.TestCase):
|
||||
os.environ.pop("CLOUD_BACKUP_DIR", None)
|
||||
else:
|
||||
os.environ["CLOUD_BACKUP_DIR"] = self._old_backup_dir
|
||||
# 恢复猴子补丁过的 server 模块函数(relay/rotate 相关测试用)。
|
||||
for name in ("_validate_sub2api_key", "_list_sub2api_models",
|
||||
"_rotate_sub2api_key"):
|
||||
# 恢复猴子补丁过的模块函数(relay 在 handler_identity,
|
||||
# rotate 仍在 server;补丁目标跟随方法定义模块)。
|
||||
for mod, name in ((handler_identity_mod, "_validate_sub2api_key"),
|
||||
(handler_identity_mod, "_list_sub2api_models"),
|
||||
(server_mod, "_rotate_sub2api_key")):
|
||||
try:
|
||||
mock.patch.object(server_mod, name).stop()
|
||||
mock.patch.object(mod, name).stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -437,7 +446,7 @@ class ServerP1TestCase(unittest.TestCase):
|
||||
{"name": "ok", "id": "y" * 65}, self.owner_key)
|
||||
self.assertEqual(s, 400, body)
|
||||
self.assertIn("64", body["error"])
|
||||
server_mod.MAX_PROJECTS_PER_USER = 1
|
||||
handler_project_mod.MAX_PROJECTS_PER_USER = 1
|
||||
# setUp 已建 1 个 owner 项目,限额 1 时再建即超限。
|
||||
s, body = _req(self.port, "POST", "/projects",
|
||||
{"name": "second"}, self.owner_key)
|
||||
@@ -488,27 +497,27 @@ class ServerP1TestCase(unittest.TestCase):
|
||||
# -- sub2api 懒开通 / 轮换端点 --------------------------------------
|
||||
def test_relay_models_lazy_provision_not_just_pending(self):
|
||||
os.environ["CLOUD_GATEWAY"] = "sub2api"
|
||||
orig_validate = server_mod._validate_sub2api_key
|
||||
orig_list = server_mod._list_sub2api_models
|
||||
orig_validate = handler_identity_mod._validate_sub2api_key
|
||||
orig_list = handler_identity_mod._list_sub2api_models
|
||||
try:
|
||||
server_mod._validate_sub2api_key = (
|
||||
handler_identity_mod._validate_sub2api_key = (
|
||||
lambda conn, uid: ("provisioned", "fake-key", 7, 9))
|
||||
server_mod._list_sub2api_models = (
|
||||
handler_identity_mod._list_sub2api_models = (
|
||||
lambda origin, key: ["model-a"])
|
||||
s, body = _req(self.port, "GET", "/relay/models",
|
||||
key=self.owner_key)
|
||||
self.assertEqual(s, 200, body)
|
||||
self.assertEqual(body["models"], ["model-a"])
|
||||
self.assertEqual(body["provisioning"], "provisioned")
|
||||
server_mod._validate_sub2api_key = (
|
||||
handler_identity_mod._validate_sub2api_key = (
|
||||
lambda conn, uid: (_ for _ in ()).throw(
|
||||
Sub2apiError("网关暂不可用")))
|
||||
s, body = _req(self.port, "GET", "/relay/models",
|
||||
key=self.owner_key)
|
||||
self.assertEqual(s, 502, body)
|
||||
finally:
|
||||
server_mod._validate_sub2api_key = orig_validate
|
||||
server_mod._list_sub2api_models = orig_list
|
||||
handler_identity_mod._validate_sub2api_key = orig_validate
|
||||
handler_identity_mod._list_sub2api_models = orig_list
|
||||
|
||||
def test_gateway_rotate_endpoint(self):
|
||||
s, body = _req(self.port, "POST", "/users/me/gateway/rotate",
|
||||
|
||||
Reference in new issue
Block a user