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

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

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

No files matched your search

+537
View File
@@ -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)