"""全自动编程系统:一次授权后的主机自动执行器(API 优先)。 本模块是“协调员只评审、人/宿主执行合并”缺口的闭合点,但 执行人不是任务 agent:任务 agent 仍然不直接执行合并/部署, 真正执行的是宿主侧的本模块(由已验证的 GitHub App installation + 显式开启标志门控),任务 agent 只交分支、 自测证据和合并决定。两者混淆就是冒充,必须分清。 设计约束(全部是硬规则,不是建议): 1. 默认关闭:FULL_AUTO_ENABLED 未显式开启,或 installation 未验证,执行器一律拒绝,不存在“默认自动合”的状态。 2. 禁止清单 fail-closed:删库、force-push(含主分支)、改仓库 可见性、读密钥、外泄密钥、删分支保护、绕分支保护,未知 动作一律拒绝,不靠调用方自觉。 3. token 只进子进程/HTTP 请求头,不进审计记录、不进日志、 不进命令行、不进文件。本模块的审计记录在写入前会把 含 token/secret/password/key 的键和 Bearer 字符串抹掉; 调用方若把 token 塞进 detail,同样会被抹掉(宁可丢 字段,不可漏 token)。 4. 主分支保护强制开:自动配置的分支保护必须同时含要求 PR、 要求状态检查(strict)、禁止 force-push、禁止删除、 管理员同样受约束(enforce_admins),缺一项就不算配好。 5. 质量门禁阻塞合并:CI 全绿 + 目录隔离检查 + 契约版本 + 协调员批准,四项缺一就不合并。门禁不过时走 CI 自动修复 循环,限次(默认 3 次),超次升级给用户,不无限重试。 6. 回滚不靠 force-push:回滚只能是 revert PR 或重新部署 上一个 tag,绝不 force-push 主分支。 7. 建仓分支要诚实:GitHub App installation token 能建的是 组织仓库(POST /orgs/{org}/repos,需 administration 写 权限);用户个人账号仓库的 POST /user/repos 需要用户 token,installation token 做不了。所以自动配置计划里, “仓库不存在 + 无 org”时返回 needs_user_once 步骤:用户 在同一次安装会话里先建一个空仓(这是安装会话的一部分, 不是此后每轮的人工步骤),App 以 All repositories 安装 后即覆盖它。此后建分支、PR、CI、合并、发版全部走 API, 不再需要人工。把这一步说成“App 已自动建仓”就是冒充, 必须如实记为 needs_user_once。 8. 绝不做网页自动化自动注册 GitHub 账号:服务条款禁止 自动化创建账号,注册有验证码、邮箱验证、可能的人机/ 手机验证,绕验证码会触发封号。本模块不提供、不调用 任何账号注册自动化,浏览器自动化只留作 API 覆盖不到 的配置页面兜底,不用来注册、不用来绕验证码。 9. 授权分层:控制台 OAuth 只证明本站身份,不带来 GitHub 权限;GitHub 权限靠 GitHub App 安装,仓库范围靠安装时 选 All repositories 一次覆盖现有与未来仓库,能力范围受 App 声明的权限封顶,新增权限必须改 App 声明并由安装方 在 GitHub 重新接受,无法静默升级。本模块不假装权限 可以自动升级:installation 的 permissions 由调用方 传入并核对,缺权限的动作直接拒绝并记为 needs_reaccept(需安装方重新接受),不静默降级执行。 零第三方依赖,只用 Python 标准库。网络一律经调用方注入 的 request_fn(约定与 cloud/github_app._github_api_request 的 http_client 相同:(method, url, headers, body) -> (status, dict)),本模块自身不发真实网络请求,单测用假 客户端即可覆盖全部路径;真实路径由调用方复用 github_app 的 urllib 实现并复用 resolve_app_token 的 “token 只进子进程环境/请求头”模式。 """ import json import os import time MAX_FIX_ATTEMPTS = 3 # 允许的自动动作(白名单,之外一律拒绝)。 ALLOWED_ACTIONS = frozenset(( "create_repo_org", "create_branch", "push_branch", "create_pr", "merge_pr", "create_release", "rollback_to_tag", "set_branch_protection", "set_default_branch", "add_ci_workflow", "verify_webhook", )) # 禁止的自动动作(黑名单,命中即拒绝,优先级高于白名单)。 FORBIDDEN_ACTIONS = frozenset(( "delete_repo", "force_push", "force_push_main", "change_visibility", "read_secret", "expose_secret", "delete_branch_protection", "bypass_branch_protection", "register_github_account", )) _SENSITIVE_KEYS = ("token", "secret", "password", "private_key", "api_key", "apikey", "auth") class FullAutoError(Exception): """全自动执行失败:reason 见各抛出处。""" def __init__(self, message, reason=None): super().__init__(message) self.reason = reason class FullAutoDenied(FullAutoError): """护栏拒绝:默认关闭、禁止动作、未知动作、权限不足。""" def is_full_auto_enabled(env=None, installation_verified=False): """执行器是否开启:显式标志 + 已验证 installation,两者缺一不可。 env 默认读 os.environ(调用方可注入 dict 做单测)。标志值 只认 1/true/yes(大小写不敏感),其它值一律视为关闭。 """ if env is None: env = os.environ raw = env.get("FULL_AUTO_ENABLED", "") flag = str(raw).strip().lower() in ("1", "true", "yes") return bool(flag and installation_verified) def validate_action(action): """校验自动动作:禁止/未知一律抛 FullAutoDenied,不返回布尔 让调用方“顺手忽略”。""" if action in FORBIDDEN_ACTIONS: raise FullAutoDenied("forbidden action: %s" % action, reason="forbidden_action") if action not in ALLOWED_ACTIONS: raise FullAutoDenied("unknown action: %s" % action, reason="unknown_action") return True def _redact_value(value): """递归抹掉敏感内容:键含 token/secret/password/key 的 一律删键;字符串里的 Bearer <...> 一律替换为 Bearer [redacted]。 """ if isinstance(value, dict): out = {} for key, item in value.items(): lowered = str(key).lower() if any(s in lowered for s in _SENSITIVE_KEYS): continue out[key] = _redact_value(item) return out if isinstance(value, (list, tuple)): return [_redact_value(item) for item in value] if isinstance(value, str): if "Bearer " in value: parts = value.split("Bearer ", 1) tail = parts[1].split()[0] if parts[1].split() else "" return value.replace("Bearer " + tail, "Bearer [redacted]") return value return value def audit_record(action, repo, actor, result, detail=None): """生成审计记录 dict:不含任何 token,调用方可直接落盘。 actor 建议写 full-auto-executor + installation id,不写 任务 agent 的名字,避免把执行人冒充成任务 agent。""" record = { "ts": time.time(), "action": action, "repo": repo, "actor": actor, "result": result, "detail": _redact_value(detail) if detail is not None else {}, } return record def append_audit_log(path, record): """审计记录追加写 JSONL:父目录不存在时创建;记录本身 已在 audit_record 里脱敏,本函数不再二次处理 token, 但调用方若直接传了含 token 的 dict,本函数不负责兜底, 必须走 audit_record 生成记录。""" directory = os.path.dirname(os.path.abspath(str(path))) if directory: os.makedirs(directory, exist_ok=True) with open(path, "a", encoding="utf-8") as handle: handle.write(json.dumps(record, ensure_ascii=False) + "\n") return record def branch_protection_payload(required_checks): """主分支保护 payload:要求 PR + 要求状态检查(strict)+ 禁止 force-push + 禁止删除 + 管理员同样受约束,缺一不可。 required_checks 为空时不算配好(调用方必须传 CI 检查名, 不能靠“无检查即通过”蒙混)。""" checks = [str(c) for c in (required_checks or []) if str(c)] if not checks: raise FullAutoError("required_checks must not be empty", reason="branch_protection_incomplete") return { "required_status_checks": { "strict": True, "contexts": checks, }, "enforce_admins": True, "required_pull_request_reviews": { "required_approving_review_count": 1, }, "restrictions": None, "allow_force_pushes": False, "allow_deletions": False, } def auto_config_plan(repo_full_name, default_branch="main", org=None, repo_exists=True, ci_workflow_present=False, required_checks=("verify",)): """一次授权后 API 自动配置的顺序计划(不发网络请求)。 每步含 step / api(None 表示需人工一次或仅核对)/ method / path / payload_summary / manual: manual=None 表示全自动;manual="user_once" 表示该步 是安装会话里唯一的人工步(仅建仓分支会出这一步); manual="verify_only" 表示仅核对、不创建(App 级 webhook 已在 App manifest 的 hook_attributes 里配好,仓库级 不再重复建 hook,重复建反而会双发)。 调用方按顺序执行,遇失败即停,不跳步继续。 """ if not repo_full_name or "/" not in str(repo_full_name): raise FullAutoError("repo_full_name must be owner/repo", reason="invalid_repo") repo = str(repo_full_name) branch = str(default_branch or "main") steps = [] if not repo_exists: if org: steps.append({ "step": "ensure_repo", "api": "POST /orgs/{org}/repos", "method": "POST", "path": "/orgs/%s/repos" % org, "payload_summary": { "name": repo.split("/", 1)[1], "private": True, }, "manual": None, }) else: steps.append({ "step": "ensure_repo", "api": None, "method": None, "path": None, "payload_summary": { "reason": "installation token cannot create a " "user-account repo via POST /user/repos; " "that endpoint needs a user token", }, "manual": "user_once", }) steps.append({ "step": "set_default_branch", "api": "PATCH /repos/{owner}/{repo}", "method": "PATCH", "path": "/repos/%s" % repo, "payload_summary": {"default_branch": branch}, "manual": None, }) steps.append({ "step": "set_branch_protection", "api": "PUT /repos/{owner}/{repo}/branches/{branch}/protection", "method": "PUT", "path": "/repos/%s/branches/%s/protection" % (repo, branch), "payload_summary": branch_protection_payload(required_checks), "manual": None, }) if not ci_workflow_present: steps.append({ "step": "add_ci_workflow", "api": "PUT /repos/{owner}/{repo}/contents/.github/workflows/host-verify.yml", "method": "PUT", "path": "/repos/%s/contents/.github/workflows/host-verify.yml" % repo, "payload_summary": {"message": "chore: add host-verify CI"}, "manual": None, }) steps.append({ "step": "verify_webhook", "api": None, "method": None, "path": None, "payload_summary": { "reason": "webhook is configured at App level via " "manifest hook_attributes; per-repo hook " "creation would double-deliver", }, "manual": "verify_only", }) return steps def run_plan(request_fn, token, plan): """顺序执行自动配置计划:request_fn 约定同 github_app 的 http_client((method, url, headers, body) -> (status, dict))。 token 只放进本次请求的 Authorization 头,不进返回值、 不进审计记录。manual 步跳过并记为 needs_user_once / verify_only;任一 API 步非 2xx 即停并抛 FullAutoError, 不跳步继续(fail-closed)。""" if not token: raise FullAutoDenied("installation token required", reason="token_missing") results = [] for step in plan: if step.get("manual") in ("user_once", "verify_only"): results.append({"step": step["step"], "status": step["manual"], "ok": step["manual"] == "verify_only"}) continue action_map = { "ensure_repo": "create_repo_org", "set_default_branch": "set_default_branch", "set_branch_protection": "set_branch_protection", "add_ci_workflow": "add_ci_workflow", "verify_webhook": "verify_webhook", } validate_action(action_map.get(step["step"], step["step"])) headers = {"Accept": "application/vnd.github+json", "User-Agent": "codex-app-server", "Authorization": "Bearer " + token} body = None payload = step.get("payload_summary") if payload is not None: body = json.dumps(payload).encode("utf-8") url = "https://api.github.com" + str(step["path"]) status, data = request_fn(step["method"], url, headers, body) ok = 200 <= status < 300 results.append({"step": step["step"], "status": status, "ok": ok}) if not ok: raise FullAutoError( "auto-config step %s failed: %s %r" % (step["step"], status, data.get("message")), reason="auto_config_step_failed") return results def merge_gate(checks, required_checks, directory_check_ok, contract_version_ok, coordinator_approved): """合并门:四项全过才返回 (True, []),否则返回 (False, [未过原因...])。checks 是 {检查名: 状态}, 状态只认 success;pending/failure/缺失一律算未过。 本函数不读 CI、不调网络,调用方把 pr/checks 的结果 传进来即可,测试与生产走同一判定。""" reasons = [] checks = checks or {} for name in (required_checks or []): if checks.get(name) != "success": reasons.append("check not green: %s" % name) if not directory_check_ok: reasons.append("directory isolation check failed") if not contract_version_ok: reasons.append("contract version mismatch") if not coordinator_approved: reasons.append("coordinator approval missing") return (not reasons, reasons) def ci_fix_decision(attempt, failures): """CI 自动修复循环决策:attempt 从 1 开始计数。 1..MAX_FIX_ATTEMPTS 返回 fix_and_rerun;超次返回 escalate(升级给用户),绝不无限重试烧模型费。""" failed = [str(f) for f in (failures or [])] if attempt <= MAX_FIX_ATTEMPTS: return {"action": "fix_and_rerun", "attempt": attempt, "failures": failed} return {"action": "escalate", "reason": "max_fix_attempts_exceeded", "attempt": attempt, "failures": failed} def release_plan(tag, merged_prs): """发版计划:release notes 从已合 PR 的标题聚合,不编造 未合 PR;回滚只能是 revert PR 或重新部署上一 tag, 绝不 force-push。merged_prs 是 [{number, title}]。""" notes = ["## Merged PRs"] for pr in (merged_prs or []): notes.append("- #%s %s" % (pr.get("number"), pr.get("title"))) return { "tag": tag, "release_notes": "\n".join(notes), "rollback": "revert PR or redeploy previous tag; " "never force-push the default branch", } def rollback_plan(previous_tag): """回滚计划:只认上一 tag,不接受 force-push 式回滚。""" if not previous_tag: raise FullAutoError("previous_tag required for rollback", reason="rollback_no_tag") return { "action": "rollback_to_tag", "previous_tag": previous_tag, "method": "redeploy previous tag (no force-push)", }