From d553f9549c1a49f36b7e6a67d203dd769f09f1fd Mon Sep 17 00:00:00 2001 From: host Date: Fri, 30 Oct 2026 15:54:19 +0000 Subject: [PATCH] feat: configurable merge gate (branch protection + directory isolation) and skill body/appendix checks - cloud/merge_gate.py: pure gate; directory check reuses the v0.3 git diff --name-only merge-base command; protection modes record (default, explicit confirm required when unprotected), enforce (always blocks when unprotected), off (explicit only). Confirm covers protection risk only, never directory violations. Protection status is caller/env-declared only; unknown is a recorded risk, not a claim of protection. - server POST /tasks/{id}/merge: applies the gate only when gate evidence is supplied (or env declares protection); no-evidence calls behave as before plus a merge_gate field in the response. Blocked merges return 409 without changing status; confirmed unprotected merges write a merge_gate_risk audit entry. - skill_seed.py: whole-directory seeding evaluated and rejected (cannot preserve tenant deletions; no benefit while the pack holds only SKILL.md); SKILL.md-only behaviour unchanged. - software-engineering SKILL.md: gate paragraph added to the body and to the coordinator + client appendix sections (v0.2 text on this base; port to v0.3 text on rebase after #199 merges). - tests: 16 new tests covering gate modes, directory reuse, body/appendix bidirectional assertions naming the missing side, SKILL.md-only seeding and tenant-edit preservation. --- cloud/merge_gate.py | 175 +++++++++++ cloud/server.py | 33 ++ cloud/skill_seed.py | 15 + cloud/skills/software-engineering/SKILL.md | 24 ++ cloud/tests/test_merge_gate_protectskill.py | 318 ++++++++++++++++++++ 5 files changed, 565 insertions(+) create mode 100644 cloud/merge_gate.py create mode 100644 cloud/tests/test_merge_gate_protectskill.py diff --git a/cloud/merge_gate.py b/cloud/merge_gate.py new file mode 100644 index 0000000..6239a83 --- /dev/null +++ b/cloud/merge_gate.py @@ -0,0 +1,175 @@ +"""合并门禁:主分支保护 + 目录隔离(纯函数,无网络、无 GitHub 调用)。 + +背景与诚实边界(先读,再用): +- 本模块**不能**替仓库开通主分支保护。保护是 GitHub 仓库设置, + 只能由仓库管理员在 GitHub 侧开通;GitHub App id 5476837 的权限 + 也不会自动变化,新权限需安装方重新接受。本模块只能在调用方 + 明确告诉它保护状态时做门禁,绝不声称“已保护”。 +- 保护状态的唯一来源是调用方传入的值或环境变量 + CLOUD_BRANCH_PROTECTION(protected / unprotected,未设即 + unknown)。unknown 不是“通过”,是“未知”:门禁把它记为风险, + 但不因此阻断——因为本服务没有可信渠道去 GitHub 核实,拿未知 + 当未保护去硬堵,会把当前所有合并(当前仓库本身未保护)全停掉。 + 想把门禁武装起来,运维要显式设置该环境变量为 unprotected, + 再由调用方在每次合并时传确认,或把模式切到 enforce。 +- 目录隔离复用 v0.3 技能的检查口径(见 + cloud/skills/software-engineering/SKILL.md“目录与分支规则”): + 人 / 宿主跑 ``git diff --name-only $(git merge-base origin/main + HEAD)...HEAD``,逐项核对每个文件是否落在拥有目录内,契约变更 + 经协调员批准的文件除外。本模块不另造一套口径: + ``check_directory_isolation`` 就是这条规则的可执行版, + ``directory_diff_command`` 返回的正是 v0.3 文本里的命令。 + v0.3 之前服务端合并端(POST /tasks/{id}/merge)只改任务状态, + 不做 git 合并、不做目录检查、不做保护检查——那是本模块要补的 + 缺口,不是重复造检查。 + +模式(CLOUD_MERGE_GATE_MODE,默认 record): +- record(默认):未保护时必须显式确认(explicit_confirm)才放行; + 无确认时阻断,错误码 branch_protection_risk_unconfirmed。 + 确认只覆盖“保护风险”,绝不覆盖目录隔离违规——越目录的写 + 只能靠 approved_exceptions(契约变更批准)放行,不能靠确认蒙混。 +- enforce:未保护时一律阻断,确认也不放行。切到 enforce 之前 + 必须先由用户决定并开通保护:升级 Pro、公开仓库、或保持现状 + 纪律 + 记风险——三者都是用户侧决定,代码里只留开关,不替选。 +- off:门禁显式关闭,只记风险不阻断。off 必须显式设置, + 非法模式值不会悄悄落到 off,而是回落到 record(fail-safe)。 +""" + +import os + +MODES = ("record", "enforce", "off") +PROTECTION_STATES = ("protected", "unprotected", "unknown") + +RISK_UNPROTECTED = "branch protection is not enabled for this repo" +RISK_UNKNOWN = "branch protection status is unknown (not declared)" +RISK_GATE_OFF = "merge gate is disabled (mode=off)" + + +def gate_mode_from_env(env=None): + """环境变量里的模式;未设或非法时回落 record,绝不回落 off。""" + raw = (env if env is not None else os.environ).get( + "CLOUD_MERGE_GATE_MODE", "") + mode = (raw or "").strip().lower() + return mode if mode in MODES else "record" + + +def protection_from_env(env=None): + """环境变量里的保护状态;未设或非法时为 unknown,不是 protected。""" + raw = (env if env is not None else os.environ).get( + "CLOUD_BRANCH_PROTECTION", "") + state = (raw or "").strip().lower() + return state if state in PROTECTION_STATES else "unknown" + + +def directory_diff_command(base="origin/main", head="HEAD"): + """v0.3 技能文本里的目录检查命令(原样口径,不另造)。""" + return "git diff --name-only $(git merge-base %s %s)...%s" % ( + base, head, head) + + +def _normalize_path(path): + return (path or "").strip().replace("\\", "/") + + +def _is_escape(path): + parts = [p for p in _normalize_path(path).split("/") if p not in ("", ".")] + return (not parts) or parts[0] == ".." or ".." in parts + + +def check_directory_isolation(files_changed, owned_dir, + approved_exceptions=()): + """v0.3 目录隔离规则的可执行版。 + + files_changed:diff 输出的文件清单(相对仓库根)。 + owned_dir:本线程唯一拥有目录,例如 apps/web;为 None/空时 + 无法核对,返回 inconclusive(不算通过,也不伪装成通过)。 + approved_exceptions:经协调员批准的契约变更文件(精确路径), + 命中者不算违规,但会记入 exceptions_used 以便合并记录核对。 + 返回 dict:passed / violations / exceptions_used / inconclusive。 + """ + owned = _normalize_path(owned_dir) + exceptions = {_normalize_path(p) for p in approved_exceptions or ()} + if not owned: + return {"passed": False, "violations": [], + "exceptions_used": [], "inconclusive": True, + "reason": "owned_dir_unknown"} + prefix = owned.rstrip("/") + "/" + violations = [] + used = [] + for raw in files_changed or []: + path = _normalize_path(raw) + if not path: + continue + if path in exceptions: + used.append(path) + continue + if path.startswith("/") or _is_escape(path) \ + or not path.startswith(prefix): + violations.append(path) + return {"passed": not violations, "violations": violations, + "exceptions_used": used, "inconclusive": False, + "reason": ""} + + +def evaluate_merge_gate(files_changed=None, owned_dir=None, + approved_exceptions=(), protection="unknown", + mode="record", explicit_confirm=False): + """合并门禁纯函数。 + + files_changed=None 表示调用方没给 diff 证据:目录检查跳过 + (directory=None),不因“没证据”就判违规,也不伪装成已检查。 + protection="unknown" 同理:记风险、不阻断。 + 返回 dict:allowed / mode / protection / directory / risks / blocks。 + blocks 为空即 allowed;每个 block 都是可查的错误码。 + """ + mode = mode if mode in MODES else "record" + protection = protection if protection in PROTECTION_STATES \ + else "unknown" + risks = [] + blocks = [] + directory = None + if files_changed is not None: + directory = check_directory_isolation( + files_changed, owned_dir, approved_exceptions) + if directory["inconclusive"]: + blocks.append("directory_owned_dir_unknown") + elif not directory["passed"]: + blocks.append("directory_isolation_violation") + if protection == "unprotected": + risks.append(RISK_UNPROTECTED) + if mode == "enforce": + blocks.append("branch_protection_enforce") + elif mode == "record" and not explicit_confirm: + blocks.append("branch_protection_risk_unconfirmed") + elif protection == "unknown": + risks.append(RISK_UNKNOWN) + if mode == "off" and protection == "unprotected": + risks.append(RISK_GATE_OFF) + return {"allowed": not blocks, "mode": mode, + "protection": protection, "directory": directory, + "risks": risks, "blocks": blocks} + + +def gate_from_request(body, env=None): + """从 /merge 请求体 + 环境变量拼出门禁输入并求值。 + + 请求体字段(全是可选的;一个都不给时行为与旧接口一致, + 只在响应里附带门禁结果,不阻断): + files_changed(str 列表)、owned_dir、approved_exceptions、 + branch_protection、merge_gate_confirm(bool)。 + 模式优先取环境变量(运维定),环境变量未设时才取请求体 + merge_gate_mode——请求体不能把运维定的 enforce 降级为 record。 + """ + body = body or {} + env_map = env if env is not None else os.environ + mode = gate_mode_from_env(env_map) \ + if env_map.get("CLOUD_MERGE_GATE_MODE") \ + else (body.get("merge_gate_mode") or "record") + protection = body.get("branch_protection") \ + or protection_from_env(env_map) + return evaluate_merge_gate( + files_changed=body.get("files_changed"), + owned_dir=body.get("owned_dir"), + approved_exceptions=body.get("approved_exceptions") or (), + protection=protection, mode=mode, + explicit_confirm=bool(body.get("merge_gate_confirm"))) diff --git a/cloud/server.py b/cloud/server.py index 3a91214..7a09334 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -74,6 +74,7 @@ from cloud.peer_messages import ensure_followups_table as _peer_ensure_followups from cloud.peer_messages import peer_message_prefix as _peer_prefix from cloud.peer_messages import row_get as _row_get from cloud.marker_tools import parse_spawn_defaults as _parse_spawn_defaults +from cloud.merge_gate import gate_from_request as _merge_gate_from_request 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 @@ -7614,6 +7615,18 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): 必须是项目 maintainer 以上(approve_merge,403)、任务 必须是 completed(其它状态一律 409,含已 merged 的重复 调用,避免二次合并混淆)。成功后写审计 merge_task。 + + 合并门禁(cloud/merge_gate,补 v0.3 技能文本检查在服务端 + 缺失的缺口;v0.3 之前本接口只改状态,不查目录、不查保护): + 请求体可选字段 files_changed / owned_dir / + approved_exceptions / branch_protection / merge_gate_confirm + / merge_gate_mode;一个都不给时行为与旧接口一致(只在响应 + 里附带门禁结果,不阻断)。给了证据才按门禁判:越目录 + (v0.3 口径)或未保护且无确认(record 模式)或未保护 + (enforce 模式)时 409 且不改状态;未保护 + 确认时放行, + 风险记入响应与 merge_gate_risk 审计。确认只覆盖保护风险, + 绝不覆盖目录违规。保护状态 unknown 时记风险、不阻断—— + 本服务没有可信渠道核实 GitHub 保护,unknown 不等于已保护。 """ row = self._fetch_task(task_id) if row is None: @@ -7631,6 +7644,21 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): "status": task["status"], }) return + body, jerr = self._read_json() + if jerr: + self._send_json(400, {"error": jerr}) + return + if body.get("files_changed") is not None and not isinstance( + body.get("files_changed"), list): + self._send_json(400, {"error": "files_changed must be a list"}) + return + gate = _merge_gate_from_request(body) + if not gate["allowed"]: + self._send_json(409, { + "error": "merge gate blocked", + "merge_gate": gate, + }) + return now = time.time() conn = self._db() try: @@ -7649,6 +7677,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "merge_task", task_id) + if gate["protection"] == "unprotected": + store.audit(user_id, task["project_id"], + "merge_gate_risk", + "%s: %s" % (task_id, "; ".join(gate["risks"]))) + task["merge_gate"] = gate self._send_json(200, task) # -- 运行时审批队列 ------------------------------------------------ diff --git a/cloud/skill_seed.py b/cloud/skill_seed.py index eac78ab..8022665 100644 --- a/cloud/skill_seed.py +++ b/cloud/skill_seed.py @@ -40,6 +40,21 @@ passes, do not claim the skills are live in production tasks -- this helper only guarantees the files land in the tenant home, idempotently, without clobbering tenant edits, and that skills/list sees them there. + +Whole-directory seeding evaluation (2026-10-30, conclusion: NOT DONE): +copying a whole skill directory instead of only SKILL.md was evaluated +and rejected, so the behaviour above is unchanged. A per-file +skip-if-differ rule would preserve a tenant-modified file's content, +but it cannot preserve a tenant's *deletion* (a missing destination +file is recopied, resurrecting a file the tenant removed on purpose) +and it would auto-seed every future pack file into tenant homes +without review -- new surface, no benefit today, since every pack +directory currently contains only SKILL.md. The hard line stands: +a tenant-modified file is never overwritten, and whole-directory +copying is not adopted unless a future evaluation proves both +content-preservation AND deletion-preservation. Until then, only +SKILL.md is seeded; extra files in a pack directory are ignored +(pinned by test_merge_gate_protectskill.py). """ import shutil diff --git a/cloud/skills/software-engineering/SKILL.md b/cloud/skills/software-engineering/SKILL.md index 5441894..7934e92 100644 --- a/cloud/skills/software-engineering/SKILL.md +++ b/cloud/skills/software-engineering/SKILL.md @@ -535,6 +535,18 @@ tests/ # QA 线程专属,含跨端一致性用例 要求 PR、要求 CI 通过、禁止直接推。目标仓库没开保护时, 协调员要在决策记录里写明风险并去开通,不能假装已保护, 不能靠“大家自觉”代替保护。 +- 合并门禁(2026-10-30 补,代码在 cloud/merge_gate.py,服务端 + POST /tasks/{id}/merge 已接线):保护状态只能由调用方或环境变量 + CLOUD_BRANCH_PROTECTION 显式声明,代码不去 GitHub 核实、不声称 + 已保护;状态 unknown 时记风险、不阻断。模式由 + CLOUD_MERGE_GATE_MODE 控制,默认 record:未保护时须显式确认 + (merge_gate_confirm)才放行,无确认阻断;enforce 模式下未保护 + 一律阻断,确认也不放行——切 enforce 前必须先由用户决定并开通 + 保护(升级 Pro、公开仓库、或保持纪律 + 记风险,三者用户侧定, + 代码不替选)。显式确认只覆盖保护风险,绝不覆盖目录隔离违规; + 目录检查复用 v0.3 的 git diff --name-only 口径,越目录只能靠 + 契约变更批准文件放行。技能播种仍只复制 SKILL.md,不做整目录复制 + (租户已改文件绝不被覆盖,见 cloud/skill_seed.py 的评估结论)。 ### 合并方式与合并记录 @@ -821,6 +833,13 @@ G8 的交付物是 v1.1 交接清单终版与下一轮 G0-G6 输入冻结清单 不批准、不合并;无 PR 时它会 409,先补 PR。 - 主分支保护:要求 PR、要求 CI 通过、禁止直接推,这是 GitHub 仓库设置, 不是本平台代码能替你开的。没开保护时记为风险并去开通,不假装已保护。 +- 合并门禁:同正文,代码在 cloud/merge_gate.py,模式由 + CLOUD_MERGE_GATE_MODE 控制,默认 record:未保护时须显式确认 + 才放行,无确认阻断;enforce 模式下未保护一律阻断,切 enforce + 前须由用户决定并开通保护(升级 Pro、公开仓库、或保持纪律 + + 记风险,三者用户侧定)。显式确认只覆盖保护风险,绝不覆盖 + 目录隔离违规;目录检查复用 v0.3 的 git diff --name-only 口径; + 技能播种仍只复制 SKILL.md,不做整目录复制。 - 合并记录:默认 merge commit;记录必须含 PR 号、合并提交 hash、 CI 运行链接、执行人(人或宿主操作,不是你自己合); 合并后删远程分支,删前确认合并提交已在主线。 @@ -1048,6 +1067,11 @@ TODO-UPDATE 要求,缺一项就不发任务)。派生提示词里的精简 契约版本、测试证据、已知问题、关联待办或 PRD 项。没有 PR 号不算完成。 - 合并门由协调员判定:Checks 全绿、目录归属通过、契约版本是冻结版、 评审通过且无未解决意见;你不自己合并、不绕过评审推主线。 +- 合并门禁:同正文,模式由 CLOUD_MERGE_GATE_MODE 控制,默认 + record:未保护时须显式确认才放行;显式确认只覆盖保护风险, + 绝不覆盖目录隔离违规;目录检查复用 v0.3 的 + git diff --name-only 口径;技能播种仍只复制 SKILL.md, + 不做整目录复制。 - 安全:密钥、口令、token 绝不进代码、commit、命令行、PR 正文; 需要调用 GitHub 时 token 只进子进程环境,不写进示例命令。 diff --git a/cloud/tests/test_merge_gate_protectskill.py b/cloud/tests/test_merge_gate_protectskill.py new file mode 100644 index 0000000..468b27e --- /dev/null +++ b/cloud/tests/test_merge_gate_protectskill.py @@ -0,0 +1,318 @@ +"""主分支保护 + 目录隔离门禁(cloud/merge_gate)与技能工程双向断言。 + +门禁口径: +- 目录隔离复用 v0.3 技能文本的 git diff --name-only 检查,不另造; + 服务端 POST /tasks/{id}/merge 在此之前只改状态、不查目录、不查保护。 +- 保护默认 record:未保护 + 无确认阻断,未保护 + 确认放行并记风险; + enforce 下未保护一律阻断;unknown 记风险、不阻断(代码无法核实 + GitHub 保护,unknown 不等于已保护,也不等于未保护)。 +- 显式确认只覆盖保护风险,绝不覆盖目录违规。 +技能工程: +- 正文与附录双向断言:每个新关键词在正文、附录都要出现,失败 + 信息指明缺失的一侧(正文缺失 / 附录缺失),不是笼统的 assertIn。 +- 播种仍只复制 SKILL.md:包内多余文件不进租户 home,租户已改 + 的 SKILL.md 绝不被覆盖;整目录复制明确不做(见 skill_seed.py)。 +""" + +import http.client +import json +import os +import sqlite3 +import sys +import tempfile +import threading +import unittest +from pathlib import Path + +try: + from cloud.auth import AuthStore + from cloud.merge_gate import ( + check_directory_isolation, directory_diff_command, + evaluate_merge_gate, gate_from_request, gate_mode_from_env, + protection_from_env) + from cloud.server import create_server + from cloud.skill_seed import ensure_codex_home_skills +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.auth import AuthStore + from cloud.merge_gate import ( + check_directory_isolation, directory_diff_command, + evaluate_merge_gate, gate_from_request, gate_mode_from_env, + protection_from_env) + from cloud.server import create_server + from cloud.skill_seed import ensure_codex_home_skills + +SKILL = Path(__file__).resolve().parents[1] / "skills" \ + / "software-engineering" / "SKILL.md" +BOTH_KEYWORDS = ("合并门禁", "CLOUD_MERGE_GATE_MODE", "显式确认", + "git diff --name-only", "不做整目录复制") + + +class MergeGateUnitTestCase(unittest.TestCase): + def test_record_unprotected_needs_confirm(self): + blocked = evaluate_merge_gate(protection="unprotected") + self.assertFalse(blocked["allowed"]) + self.assertIn("branch_protection_risk_unconfirmed", + blocked["blocks"]) + ok = evaluate_merge_gate(protection="unprotected", + explicit_confirm=True) + self.assertTrue(ok["allowed"]) + self.assertIn("branch protection is not enabled", + " ".join(ok["risks"])) + + def test_enforce_blocks_even_with_confirm(self): + gate = evaluate_merge_gate(protection="unprotected", mode="enforce", + explicit_confirm=True) + self.assertFalse(gate["allowed"]) + self.assertIn("branch_protection_enforce", gate["blocks"]) + + def test_off_allows_but_records_risk(self): + gate = evaluate_merge_gate(protection="unprotected", mode="off") + self.assertTrue(gate["allowed"]) + self.assertTrue(gate["risks"]) + + def test_unknown_is_risk_not_block(self): + gate = evaluate_merge_gate(protection="unknown") + self.assertTrue(gate["allowed"]) + self.assertTrue(gate["risks"]) + self.assertEqual(gate["directory"], None) + + def test_protected_allows(self): + self.assertTrue(evaluate_merge_gate( + protection="protected")["allowed"]) + + def test_invalid_mode_falls_back_to_record_not_off(self): + self.assertEqual(gate_mode_from_env({"CLOUD_MERGE_GATE_MODE": "x"}), + "record") + self.assertEqual(gate_mode_from_env({}), "record") + self.assertEqual(protection_from_env({}), "unknown") + gate = gate_from_request({}, env={"CLOUD_MERGE_GATE_MODE": "bogus", + "CLOUD_BRANCH_PROTECTION": + "unprotected"}) + self.assertFalse(gate["allowed"]) + + def test_directory_reuses_v03_rule(self): + self.assertEqual( + directory_diff_command(), + "git diff --name-only $(git merge-base origin/main HEAD)...HEAD") + ok = check_directory_isolation( + ["apps/web/a.py", "apps/web/sub/b.py"], "apps/web") + self.assertTrue(ok["passed"]) + self.assertEqual(ok["violations"], []) + bad = check_directory_isolation( + ["apps/web/a.py", "apps/ios/b.py", "server/c.py"], "apps/web") + self.assertFalse(bad["passed"]) + self.assertEqual(sorted(bad["violations"]), + ["apps/ios/b.py", "server/c.py"]) + # 目录前缀边界:apps/web2 不是 apps/web 的子目录。 + edge = check_directory_isolation(["apps/web2/x.py"], "apps/web") + self.assertEqual(edge["violations"], ["apps/web2/x.py"]) + # 越界路径与契约变更批准:前者违规,后者放行但要记入 used。 + esc = check_directory_isolation(["../secret.py", "/abs.py"], + "apps/web") + self.assertEqual(sorted(esc["violations"]), + ["../secret.py", "/abs.py"]) + exc = check_directory_isolation( + ["apps/web/a.py", "contracts/api.md"], "apps/web", + approved_exceptions=["contracts/api.md"]) + self.assertTrue(exc["passed"]) + self.assertEqual(exc["exceptions_used"], ["contracts/api.md"]) + + def test_confirm_never_overrides_directory_violation(self): + gate = evaluate_merge_gate( + files_changed=["apps/ios/b.py"], owned_dir="apps/web", + protection="unprotected", explicit_confirm=True) + self.assertFalse(gate["allowed"]) + self.assertIn("directory_isolation_violation", gate["blocks"]) + + def test_files_without_owned_dir_is_inconclusive_and_blocks(self): + gate = evaluate_merge_gate(files_changed=["apps/web/a.py"], + owned_dir=None) + self.assertFalse(gate["allowed"]) + self.assertIn("directory_owned_dir_unknown", gate["blocks"]) + no_evidence = evaluate_merge_gate() + self.assertTrue(no_evidence["allowed"]) + self.assertIsNone(no_evidence["directory"]) + + +class SkillBidirectionalTestCase(unittest.TestCase): + def test_protectskill_keywords_in_body_and_appendix(self): + text = SKILL.read_text(encoding="utf-8") + main, sep, appendix = text.partition("## 附录:角色模板") + self.assertTrue(sep, "appendix marker missing") + for kw in BOTH_KEYWORDS: + self.assertIn(kw, main, "正文缺失:" + kw) + self.assertIn(kw, appendix, "附录缺失:" + kw) + + def test_gate_in_coordinator_and_client_appendix_sections(self): + text = SKILL.read_text(encoding="utf-8") + coord = text.split("## 附录角色:00-coordinator", 1)[1].split( + "## 附录角色:01-", 1)[0] + client = text.split("## 附录角色:04-client-developer", 1)[1].split( + "## 附录角色:05-", 1)[0] + self.assertIn("合并门禁", coord, "附录协调员章节缺失:合并门禁") + self.assertIn("合并门禁", client, "附录客户端章节缺失:合并门禁") + + +class SkillSeedNoWholeDirTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + + def tearDown(self): + self.tmp.cleanup() + + def _pack(self): + src = os.path.join(self.tmp.name, "pack", "demo-skill") + os.makedirs(src, exist_ok=True) + open(os.path.join(src, "SKILL.md"), "w").write("pack version\n") + # 整目录复制才会带走的多余文件:现行行为必须忽略它们。 + open(os.path.join(src, "extra.txt"), "w").write("extra\n") + os.makedirs(os.path.join(src, "templates"), exist_ok=True) + open(os.path.join(src, "templates", "x.md"), "w").write("x\n") + return os.path.join(self.tmp.name, "pack") + + def test_only_skill_md_seeded_and_tenant_edit_never_overwritten(self): + home = os.path.join(self.tmp.name, "home") + out = ensure_codex_home_skills(home, self._pack()) + self.assertEqual(out["skills_copied"], ["demo-skill"]) + dest_dir = os.path.join(home, "skills", "demo-skill") + self.assertTrue(os.path.isfile( + os.path.join(dest_dir, "SKILL.md"))) + self.assertFalse(os.path.exists( + os.path.join(dest_dir, "extra.txt"))) + self.assertFalse(os.path.exists( + os.path.join(dest_dir, "templates"))) + dest = os.path.join(dest_dir, "SKILL.md") + open(dest, "w").write("tenant edited version\n") + out2 = ensure_codex_home_skills(home, self._pack()) + self.assertEqual(out2["skills_skipped_differ"], ["demo-skill"]) + self.assertEqual(open(dest).read(), "tenant edited version\n") + + +class MergeEndpointGateTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + self.auth = AuthStore(self.db_path) + self.owner_id, self.owner_key = self.auth.create_user("owner") + self.server = create_server(self.db_path, "127.0.0.1", 0) + self.port = self.server.server_address[1] + self.thread = threading.Thread( + target=self.server.serve_forever, daemon=True) + self.thread.start() + status, self.project = self._req( + "POST", "/projects", {"name": "demo"}, self.owner_key) + self.assertEqual(status, 201) + self._saved_env = {k: os.environ.get(k) for k in + ("CLOUD_MERGE_GATE_MODE", + "CLOUD_BRANCH_PROTECTION")} + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + for key, value in self._saved_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + try: + self.auth.conn.close() + except Exception: + pass + self.tmp.cleanup() + + def _req(self, method, path, body=None, key=None): + conn = http.client.HTTPConnection( + "127.0.0.1", self.port, timeout=10) + hdrs = {} + payload = None + if body is not None: + payload = json.dumps(body).encode("utf-8") + hdrs["Content-Type"] = "application/json" + if key is not None: + hdrs["X-API-Key"] = key + try: + conn.request(method, path, body=payload, headers=hdrs) + resp = conn.getresponse() + data = resp.read() + finally: + conn.close() + try: + parsed = json.loads(data.decode("utf-8")) if data else None + except ValueError: + parsed = None + return resp.status, parsed + + def _completed_task(self): + status, task = self._req( + "POST", "/tasks", + {"project_id": self.project["id"], "prompt": "x"}, + self.owner_key) + self.assertEqual(status, 201) + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + "UPDATE tasks SET status = 'completed' WHERE id = ?", + (task["id"],)) + conn.commit() + finally: + conn.close() + return task + + def test_no_gate_fields_behaves_like_old_endpoint(self): + task = self._completed_task() + status, merged = self._req( + "POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key) + self.assertEqual(status, 200) + self.assertEqual(merged["status"], "merged") + self.assertIn("merge_gate", merged) + + def test_directory_violation_blocks_without_changing_status(self): + task = self._completed_task() + status, body = self._req( + "POST", "/tasks/%s/merge" % task["id"], + {"files_changed": ["apps/web/a.py", "apps/ios/b.py"], + "owned_dir": "apps/web"}, + self.owner_key) + self.assertEqual(status, 409) + self.assertIn("directory_isolation_violation", + body["merge_gate"]["blocks"]) + _s, got = self._req("GET", "/tasks/%s" % task["id"], + key=self.owner_key) + self.assertEqual(got["status"], "completed") + + def test_unprotected_confirm_allows_and_audits_risk(self): + task = self._completed_task() + status, body = self._req( + "POST", "/tasks/%s/merge" % task["id"], + {"branch_protection": "unprotected"}, + self.owner_key) + self.assertEqual(status, 409) + task2 = self._completed_task() + status, merged = self._req( + "POST", "/tasks/%s/merge" % task2["id"], + {"branch_protection": "unprotected", + "merge_gate_confirm": True}, + self.owner_key) + self.assertEqual(status, 200) + self.assertEqual(merged["merge_gate"]["protection"], "unprotected") + audit = self.auth.list_audit(self.project["id"]) + self.assertTrue(any(a["action"] == "merge_gate_risk" + for a in audit)) + + def test_enforce_env_blocks_even_with_confirm(self): + os.environ["CLOUD_MERGE_GATE_MODE"] = "enforce" + os.environ["CLOUD_BRANCH_PROTECTION"] = "unprotected" + task = self._completed_task() + status, body = self._req( + "POST", "/tasks/%s/merge" % task["id"], + {"merge_gate_confirm": True}, + self.owner_key) + self.assertEqual(status, 409) + self.assertIn("branch_protection_enforce", + body["merge_gate"]["blocks"]) + + +if __name__ == "__main__": + unittest.main()