diff --git a/cloud/handler_automation.py b/cloud/handler_automation.py new file mode 100644 index 0000000..471ae12 --- /dev/null +++ b/cloud/handler_automation.py @@ -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) diff --git a/cloud/handler_fullauto.py b/cloud/handler_fullauto.py new file mode 100644 index 0000000..69b445d --- /dev/null +++ b/cloud/handler_fullauto.py @@ -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 diff --git a/cloud/handler_identity.py b/cloud/handler_identity.py new file mode 100644 index 0000000..80a3cc1 --- /dev/null +++ b/cloud/handler_identity.py @@ -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], + }) diff --git a/cloud/handler_oauth.py b/cloud/handler_oauth.py new file mode 100644 index 0000000..2817817 --- /dev/null +++ b/cloud/handler_oauth.py @@ -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 ( + "" + "OAuth login failed" + "

OAuth login failed

" + "

reason: %s

%s

" + "

Please restart login from the login page.

" + "" + % (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 = ( + "

Your previous API key has been rotated and no longer works.

" + if rotated else + "

Previous keys still work (multi-key). Revoke unused keys " + "via the API keys management endpoints.

" + if not created else "") + return ( + "" + "OAuth login succeeded" + "

OAuth login succeeded

" + "

provider: %s

" + "

login: %s

" + "

user_id: %s

" + "

new user: %s

" + "%s" + "

Your API key (shown only once, save it now):

" + "

%s

" + "

Please save it immediately. It will not be shown again " + "after you leave or refresh this page." + "

" + "

Use it as the X-API-Key header for API calls.

" + "" + % (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 ( + "" + "OAuth binding succeeded" + "

OAuth binding succeeded

" + "

provider: %s

" + "

login: %s

" + "

Your external identity is now bound to your account.

" + "

You can verify it from your bindings list.

" + "" + % (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 ( + "" + "GitHub App setup failed" + "

GitHub App setup failed

" + "

reason: %s

%s

" + "" + % (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 = ( + "" + "Create GitHub App" + "

Create GitHub App

" + "

App name: %s

" + "

Webhook URL: %s

" + "

Permissions: %s

" + "

Events: %s

" + "

Click Submit to create the app on GitHub. GitHub will " + "redirect back here to finish setup.

" + "
" + "" + "" + "
" + "" + "" + % (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 = ( + "" + "GitHub App created" + "

GitHub App created

" + "

App: %s

" + "

App ID: %s

" + "

Install it on your repositories:

" + "

" + "Install this GitHub App

" + "

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.

" + "" + % (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) diff --git a/cloud/handler_project.py b/cloud/handler_project.py new file mode 100644 index 0000000..ed37a2d --- /dev/null +++ b/cloud/handler_project.py @@ -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 diff --git a/cloud/handler_support.py b/cloud/handler_support.py new file mode 100644 index 0000000..a479461 --- /dev/null +++ b/cloud/handler_support.py @@ -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) diff --git a/cloud/server.py b/cloud/server.py index ef4db71..1c0911e 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -38,7 +38,6 @@ audit_log 三张表;本 server 用 projects/tasks 两张表,表名不冲突 import argparse import hashlib -import html import json import logging import os @@ -52,16 +51,10 @@ import threading import time import uuid from collections import deque -from contextlib import contextmanager from decimal import Decimal from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import parse_qs, quote, urlparse -import urllib.error -import urllib.request +from urllib.parse import parse_qs, urlparse -from cloud.auth import ROLES as _MEMBER_ROLES -from cloud.auth import AuthStore -from cloud.coordinator import parse_parts as _coord_parse_parts from cloud.coordinator import validate_parts as _coord_validate_parts from cloud.cost import CostStore from cloud.cost import unpriced_token_cap @@ -70,7 +63,6 @@ from cloud.gates import resolve_inside as _gate_resolve_inside from cloud.ledger import export_chain as _ledger_export from cloud.ledger import verify_chain as _ledger_verify from cloud.quota import decide as _quota_decide -from cloud.quota import default_quota_usd_micros as _quota_default from cloud.quota import monthly_usage as _quota_monthly_usage from cloud.quota import quota_for_owner as _quota_for_owner from cloud.timeouts import STALE_SWEEP_SECONDS as _STALE_SWEEP @@ -82,7 +74,6 @@ from cloud.db import init_postgres_schema as _init_postgres_schema from cloud.db import PostgresIntegrityError as _PgIntegrityError from cloud.db import PostgresOperationalError as _PgOperationalError from cloud.goal_command import parse_goal_command as _parse_goal_command -from cloud.marker_tools import parse_first_run_at as _parse_first_run_at from cloud.peer_messages import deliver_peer_message as _deliver_peer_message from cloud.peer_messages import ensure_followups_table as _peer_ensure_followups from cloud.peer_messages import peer_message_prefix as _peer_prefix @@ -107,21 +98,13 @@ from cloud.merge_gate_hard import hard_mode_from_env as _merge_gate_hard_mode from cloud.tree import DEFAULT_MAX_DEPTH as _TREE_DEFAULT_DEPTH from cloud.tree import DEFAULT_MAX_ENTRIES as _TREE_DEFAULT_ENTRIES from cloud.tree import build_tree as _build_tree -from cloud.automations import cancel_automation as _auto_cancel -from cloud.automations import create_automation as _auto_create -from cloud.automations import get_automation as _auto_get -from cloud.automations import list_automation_runs as _auto_runs -from cloud.automations import update_automation as _auto_update from cloud.lifecycle import add_ref as _life_add_ref -from cloud.lifecycle import bind_automation as _life_bind from cloud.lifecycle import create_snapshot as _life_snapshot from cloud.lifecycle import delete_ref as _life_delete_ref -from cloud.lifecycle import get_binding as _life_get_binding from cloud.lifecycle import latest_snapshot as _life_latest_snapshot from cloud.lifecycle import list_refs as _life_list_refs from cloud.lifecycle import list_snapshots as _life_list_snapshots from cloud.lifecycle import mark_restored as _life_mark_restored -from cloud.lifecycle import unbind_automation as _life_unbind from cloud.wait_ops import build_pr_diagnostics as _wait_build_diag from cloud.wait_ops import create_handoff as _handoff_create from cloud.wait_ops import decode_wait_cursor as _wait_decode_cursor @@ -137,8 +120,6 @@ from cloud.wait_ops import poll_task_statuses as _wait_poll # noqa: F401 from cloud.wait_ops import report_handoff as _handoff_report from cloud.wait_ops import worktree_status as _wait_worktree_status from cloud.shared_home_mcp import sync_shared_home_mcp as _sync_shared_home_mcp -from cloud.automations import ensure_automations_tables as _auto_ensure -from cloud.automations import list_automations as _auto_list from cloud.resident import create_watch as _res_create from cloud.resident import end_watch as _res_end from cloud.resident import ensure_resident_tables as _res_ensure @@ -149,12 +130,6 @@ from cloud.resident import pause_watch as _res_pause from cloud.resident import resident_metrics as _res_metrics from cloud.resident import resume_watch as _res_resume from cloud.resident import update_watch as _res_update -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.automations import triage_inbox as _auto_triage from cloud.approval_timeout import approval_timeout_stats as _appr_timeout_stats from cloud.approval_timeout import evaluate_approval_timeout as _appr_timeout_eval from cloud.approval_timeout import recommend_approval_timeout as _appr_timeout_recommend @@ -167,11 +142,6 @@ from cloud.approvals import list_approvals as _appr_list from cloud.attempt_ledger import aggregate_attempts as _aggregate_attempts from cloud.attempt_ledger import apply_to_records as _apply_attempts from cloud.attempt_ledger import read_attempt_rows as _read_attempt_rows -from cloud.auto_memory import AutoMemoryStore -from cloud.auto_memory import process_due_tasks as _auto_process_due -from cloud.identity import IdentityStore -from cloud.kb import KBStore -from cloud.memory import MemoryStore from cloud.metrics import build_report as _build_metrics_report from cloud.metrics import compute_metrics as _compute_metrics from cloud.pr import PrSkipped, PrStepError @@ -190,54 +160,22 @@ from cloud.workers import ( heartbeat_worker as _registry_heartbeat, list_workers as _registry_list, ) -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.relay import RelayError as _RelayError -from cloud.relay import get_relay_models as _get_relay_models from cloud.sub2api import Sub2apiError as _Sub2apiError from cloud.sub2api import ensure_gateway_keys_table as _ensure_gateway_keys -from cloud.sub2api import ensure_user_gateway_key as _ensure_sub2api_key from cloud.sub2api import gateway_name as _gateway_name -from cloud.sub2api import gateway_origin as _gateway_origin -from cloud.sub2api import list_gateway_models as _list_sub2api_models from cloud.sub2api import rotate_user_gateway_key as _rotate_sub2api_key from cloud.sub2api import validate_or_rotate_gateway_key as _validate_sub2api_key -from cloud.repos import validate_repo_url from cloud.push import ( - create_subscription as _push_create, - delete_subscription as _push_delete, dispatch_event as _push_dispatch, list_subscriptions as _push_list, ) from cloud.github_app import ( - DEFAULT_APP_NAME, - DEFAULT_APP_URL, - AppError, - SetupStateStore, - build_app_manifest, claim_fix_review, - exchange_manifest_code, - find_installation_for_repo, get_app_config, - list_installations, parse_installation_event, - public_app_status, record_installation_repos, remove_installation, remove_installation_repos, - save_app_config, set_fix_review_task_id, ) from cloud.webhooks import ( @@ -249,21 +187,26 @@ from cloud.webhooks import ( verify_github_signature_any, verify_slack_signature, ) -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.handler_support import ( + _apply_default_quota as _apply_default_quota, + _auth_store as _auth_store, + _default_quota_usd_micros as _default_quota_usd_micros, + _ensure_followups_table as _ensure_followups_table, + _ensure_quotas_table as _ensure_quotas_table, + _env_int as _env_int, + _environment_to_dict as _environment_to_dict, + _global_admin_ids as _global_admin_ids, + _task_to_dict as _task_to_dict, + 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, ) -from cloud.github_app import resolve_app_token as _resolve_app_token +from cloud.handler_project import _validate_env_branch as _validate_env_branch # noqa: F401 # tests import it from cloud.server +from cloud.handler_oauth import OAuthMixin +from cloud.handler_identity import IdentityMemoryMixin +from cloud.handler_project import ProjectMixin +from cloud.handler_automation import AutomationMixin +from cloud.handler_fullauto import FullAutoMixin # PR 事件推送失败时记日志(systemd 下进 journal);handler 的 # log_message 被重写为静默(测试输出干净),所以不用它。 @@ -288,17 +231,8 @@ TRACE_DEFAULT_DIR = "/opt/codex-app-server/work/host-traces" # 单用户项目数上限、项目名长度上限、自定义项目 id 长度上限。 # 均可经环境变量调(运维侧),缺省值见下方;改动只影响新项目, # 已有超限项目不受影响(不删、不改名)。 -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 -MAX_PROJECTS_PER_USER = _env_int("CODEX_MAX_PROJECTS_PER_USER", 50) -MAX_PROJECT_NAME_LEN = _env_int("CODEX_MAX_PROJECT_NAME_LEN", 100) -MAX_PROJECT_ID_LEN = _env_int("CODEX_MAX_PROJECT_ID_LEN", 64) # 协调员父任务预留:每分工至少预留的美元额度(微美元)。 # 父任务准入只查一次时,N 个分工可解锁 N 倍消费;本预留要求 # 剩余配额 >= 分工数 × 本值,否则父任务直接 402(见 @@ -1423,15 +1357,6 @@ def _ensure_task_steps_table(conn): " ON task_steps(task_id, step_no)") -def _ensure_followups_table(conn): - """幂等建后续消息表:委托给 peer_messages(两边同一份 DDL, - server init_db、各 followup 接口与 worker 投递路径共用)。 - 老库第一次跑到这里时自动补表补列,不丢数据。DOUBLE - PRECISION 在 SQLite 与 Postgres 下都可用(SQLite 按 NUMERIC - 亲和处理)。""" - _peer_ensure_followups(conn) - - def _followup_to_dict(row): return { "id": row["id"], @@ -1578,19 +1503,6 @@ def _ensure_environments_table(conn): " WHERE status != 'archived'") -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_executor(): """新任务未指定执行器时的默认值:docker。 @@ -1612,172 +1524,11 @@ def _default_executor(): return "docker" -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()} - - # -- 全自动执行器 HTTP 接线(cloud/full_auto.py 的薄端点层) -------- # 只做接线,不发明新护栏:开关、动作白名单、分支保护、脱敏、 # 限次修复全部复用 full_auto.py;本层只负责认证、管理员门、 # installation 验证、token 解析与审计落盘。token 只进请求头, # 绝不进命令行、文件、审计记录与 HTTP 响应。 -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 - - -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 _backfill_default_quotas(conn): @@ -1805,45 +1556,6 @@ def _backfill_default_quotas(conn): return 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 - - -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"], - } - - def _is_postgres_backend(): backend = os.environ.get("CLOUD_DB_BACKEND") or "sqlite" return str(backend).strip().lower() in ("postgres", "postgresql") @@ -1924,101 +1636,11 @@ def init_db(db_path): conn.close() -@contextmanager -def _auth_store(db_path): - """每请求一个 AuthStore 实例(自带新连接),用完即关闭。""" - store = AuthStore(db_path) - try: - yield store - finally: - try: - store.conn.close() - except Exception: - pass - - # 注意:此处曾有一份与 cloud.peer_messages.row_get 逐行相同的本地 # _row_get(try return row[name] except KeyError/IndexError/TypeError # -> None),它遮蔽了文件顶部的同名 import(F811 + mypy no-redef)。 # 两份实现语义完全相同,删本地定义后全部调用点解析到 import 的 # 同一份实现,行为不变;见 test_server_row_get_unified 回归测试。 -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, - } - - -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, - } def ensure_kill_requests_table(conn): @@ -2563,7 +2185,8 @@ def _thread_non_goal_guard(task): "status": task["status"]} return None -class CloudControlPlaneHandler(BaseHTTPRequestHandler): +class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, AutomationMixin, FullAutoMixin, + BaseHTTPRequestHandler): """控制面请求处理:除 /health 与 /webhooks/* 外一律要求 X-API-Key。""" protocol_version = "HTTP/1.1" @@ -3123,679 +2746,6 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): else: self._send_json(404, {"error": "not found"}) - # -- 环境 ---------------------------------------------------------- - 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 _dispatch_project(self, user_id, method, path, query): rest = path[len("/projects/"):] @@ -4196,685 +3146,6 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): return self._send_json(404, {"error": "not found"}) - 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], - }) # -- 任务 ---------------------------------------------------------- # -- 任务附件 ------------------------------------------------------ @@ -10249,860 +8520,6 @@ pid 须为整数、address 须为字符串,否则 400(注册表里的 } self._send_json(200, report) - # -- 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 ( - "" - "OAuth login failed" - "

OAuth login failed

" - "

reason: %s

%s

" - "

Please restart login from the login page.

" - "" - % (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 = ( - "

Your previous API key has been rotated and no longer works.

" - if rotated else - "

Previous keys still work (multi-key). Revoke unused keys " - "via the API keys management endpoints.

" - if not created else "") - return ( - "" - "OAuth login succeeded" - "

OAuth login succeeded

" - "

provider: %s

" - "

login: %s

" - "

user_id: %s

" - "

new user: %s

" - "%s" - "

Your API key (shown only once, save it now):

" - "

%s

" - "

Please save it immediately. It will not be shown again " - "after you leave or refresh this page." - "

" - "

Use it as the X-API-Key header for API calls.

" - "" - % (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 ( - "" - "OAuth binding succeeded" - "

OAuth binding succeeded

" - "

provider: %s

" - "

login: %s

" - "

Your external identity is now bound to your account.

" - "

You can verify it from your bindings list.

" - "" - % (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 ( - "" - "GitHub App setup failed" - "

GitHub App setup failed

" - "

reason: %s

%s

" - "" - % (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 = ( - "" - "Create GitHub App" - "

Create GitHub App

" - "

App name: %s

" - "

Webhook URL: %s

" - "

Permissions: %s

" - "

Events: %s

" - "

Click Submit to create the app on GitHub. GitHub will " - "redirect back here to finish setup.

" - "
" - "" - "" - "
" - "" - "" - % (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 = ( - "" - "GitHub App created" - "

GitHub App created

" - "

App: %s

" - "

App ID: %s

" - "

Install it on your repositories:

" - "

" - "Install this GitHub App

" - "

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.

" - "" - % (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) - - # -- 全自动执行器 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}) # -- API keys 管理 --------------------------------------------------- def _user_keys_list(self): @@ -11216,488 +8633,6 @@ pid 须为整数、address 须为字符串,否则 400(注册表里的 self._send_json(200, {"rotated": True, "provisioning": status, "sub2api_user_id": sub_id}) - # -- 推送订阅与分发 --------------------------------------------------- - 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) - # -- 常驻监听任务(见 cloud/resident.py)------------------- # 权限口径与自动化同款:创建/更新/暂停/恢复/结束用 # create_task,读用 read;状态变更记审计。创建不走配额门 @@ -11840,24 +8775,6 @@ pid 须为整数、address 须为字符串,否则 400(注册表里的 store.audit(user_id, project_id, audit_action, watch_id) self._send_json(200, watch) - 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) - def _handle_github_webhook(self): """GitHub webhook:验签 -> delivery 去重 -> 解析 -> 执行建任务。 diff --git a/cloud/tests/test_handler_split_conformance.py b/cloud/tests/test_handler_split_conformance.py new file mode 100644 index 0000000..446207f --- /dev/null +++ b/cloud/tests/test_handler_split_conformance.py @@ -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() diff --git a/cloud/tests/test_p1_security_server.py b/cloud/tests/test_p1_security_server.py index 2196586..5e19a46 100644 --- a/cloud/tests/test_p1_security_server.py +++ b/cloud/tests/test_p1_security_server.py @@ -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",