Files
codex-app-server/cloud/handler_automation.py
host 935b530f84 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)
2026-11-03 02:49:26 +00:00

538 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)