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.
This commit is contained in:
host committed 2026-11-03 02:48:55 +00:00
1 parent 44616a03dc
commit d553f9549c
5 files changed
+565

No files matched your search

+175
View File
@@ -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")))
+33
View File
@@ -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 peer_message_prefix as _peer_prefix
from cloud.peer_messages import row_get as _row_get from cloud.peer_messages import row_get as _row_get
from cloud.marker_tools import parse_spawn_defaults as _parse_spawn_defaults 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_DEPTH as _TREE_DEFAULT_DEPTH
from cloud.tree import DEFAULT_MAX_ENTRIES as _TREE_DEFAULT_ENTRIES from cloud.tree import DEFAULT_MAX_ENTRIES as _TREE_DEFAULT_ENTRIES
from cloud.tree import build_tree as _build_tree from cloud.tree import build_tree as _build_tree
@@ -7614,6 +7615,18 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
必须是项目 maintainer 以上(approve_merge,403)、任务 必须是项目 maintainer 以上(approve_merge,403)、任务
必须是 completed(其它状态一律 409,含已 merged 的重复 必须是 completed(其它状态一律 409,含已 merged 的重复
调用,避免二次合并混淆)。成功后写审计 merge_task。 调用,避免二次合并混淆)。成功后写审计 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) row = self._fetch_task(task_id)
if row is None: if row is None:
@@ -7631,6 +7644,21 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"status": task["status"], "status": task["status"],
}) })
return 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() now = time.time()
conn = self._db() conn = self._db()
try: try:
@@ -7649,6 +7677,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
conn.close() conn.close()
with _auth_store(self.server.db_path) as store: with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"], "merge_task", task_id) 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) self._send_json(200, task)
# -- 运行时审批队列 ------------------------------------------------ # -- 运行时审批队列 ------------------------------------------------
+15
View File
@@ -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, this helper only guarantees the files land in the tenant home,
idempotently, without clobbering tenant edits, and that idempotently, without clobbering tenant edits, and that
skills/list sees them there. 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 import shutil
@@ -535,6 +535,18 @@ tests/ # QA 线程专属,含跨端一致性用例
要求 PR、要求 CI 通过、禁止直接推。目标仓库没开保护时, 要求 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 时它会 409,先补 PR。
- 主分支保护:要求 PR、要求 CI 通过、禁止直接推,这是 GitHub 仓库设置, - 主分支保护:要求 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、 - 合并记录:默认 merge commit;记录必须含 PR 号、合并提交 hash、
CI 运行链接、执行人(人或宿主操作,不是你自己合); CI 运行链接、执行人(人或宿主操作,不是你自己合);
合并后删远程分支,删前确认合并提交已在主线。 合并后删远程分支,删前确认合并提交已在主线。
@@ -1048,6 +1067,11 @@ TODO-UPDATE 要求,缺一项就不发任务)。派生提示词里的精简
契约版本、测试证据、已知问题、关联待办或 PRD 项。没有 PR 号不算完成。 契约版本、测试证据、已知问题、关联待办或 PRD 项。没有 PR 号不算完成。
- 合并门由协调员判定:Checks 全绿、目录归属通过、契约版本是冻结版、 - 合并门由协调员判定:Checks 全绿、目录归属通过、契约版本是冻结版、
评审通过且无未解决意见;你不自己合并、不绕过评审推主线。 评审通过且无未解决意见;你不自己合并、不绕过评审推主线。
- 合并门禁:同正文,模式由 CLOUD_MERGE_GATE_MODE 控制,默认
record:未保护时须显式确认才放行;显式确认只覆盖保护风险,
绝不覆盖目录隔离违规;目录检查复用 v0.3 的
git diff --name-only 口径;技能播种仍只复制 SKILL.md,
不做整目录复制。
- 安全:密钥、口令、token 绝不进代码、commit、命令行、PR 正文; - 安全:密钥、口令、token 绝不进代码、commit、命令行、PR 正文;
需要调用 GitHub 时 token 只进子进程环境,不写进示例命令。 需要调用 GitHub 时 token 只进子进程环境,不写进示例命令。
+318
View File
@@ -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()