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