diff --git a/cloud/skills/software-engineering/SKILL.md b/cloud/skills/software-engineering/SKILL.md index e39559b..e792d00 100644 --- a/cloud/skills/software-engineering/SKILL.md +++ b/cloud/skills/software-engineering/SKILL.md @@ -21,7 +21,7 @@ G7 发布后运维与观察期、G8 迭代与规模化,以及下面“已定 中间由主机自动执行器(`cloud/full_auto.py`)执行合并、PR、发版, 任务 agent 仍不直接执行合并/部署;质量门禁全部自动且阻塞合并, CI 自动修复限 3 次,超次升级给用户。 -装机路径见同目录 `README.md`:源码位置是 `cloud/skills/software-engineering/SKILL.md`, +装机路径见同目录 `README.md`,变更记录见同目录 `CHANGELOG.md`:源码位置是 `cloud/skills/software-engineering/SKILL.md`, 播种后租户内位置是 `/skills/software-engineering/SKILL.md`。 ## 何时用 @@ -627,6 +627,118 @@ tests/ # QA 线程专属,含跨端一致性用例 较经济的模型,具体以项目实际可用模型和配额为准; 配额将满时先停新线程,不先透支再解释。 +> 临时版(v2 provisional,待用户确认后才算定):本节源自首轮草稿 +> `code-to10-skill/v05-QA-viewport-rewrite-DRAFT.md` 的视口版 QA 全链路, +> 插在“失败、续跑与成本”末尾;原文的跨端版仍属 #244 停下点, +> 本临时版不替用户定 B4 的最终措辞,改动清单对照表见草稿原文。 + +## QA 全链路(视口版):测试环节跑通各个和所有业务链路 + +本节是“独立审计与 QA”部分的补充验收标准,与自动推回、 +推倒重做、三条收紧并列,不覆盖已有规则;已有规则语义不改, +只补测试机制。 + +### 每个客户端交付前自测:三项缺一不算自测完 + +- 单元测试 + 对照契约的集成测试 + 手工冒烟清单,三项缺一 + 不算自测完。手工冒烟四步缺一不可:启动、登录、核心 agent 任务、 + 异常提示。代码 `check_self_test` 按这三项逐项核对,缺哪项列哪项。 +- 三项是下限,不是替换:04-client 附录章的交付前自测第 4 项 + (界面文案、状态词、字段展示与契约、设计规范一致的自查) + 继续有效,不得因“三项”之名删掉。 + (附注:第 4 项措辞里的“跨端”二字是否随视口口径改写, + 本草稿不擅自改,留给移植轨确认。) + +### QA 分工:单端复核与跨视口 E2E 是两条线 + +- QA 分单端复核(复核各客户端自测证据,不一个人包全端, + 跨视口 E2E 可再分 1 个线程)与跨视口 E2E(QA 主做)。 +- 跨视口 E2E 的口径:同一个 agent 任务、同一份数据,跑遍 + 手机 / 平板 / 桌面浏览器宽度,各视口的结果、文案、状态词、 + 同步状态是否一致;不一致的标明是缺陷还是刻意差异, + 刻意差异须有依据(设计条款或本地化规则等),不得以 + “刻意”掩盖缺陷——无依据的“刻意”按缺陷处理(代码 + `check_consistency`:差异未分类、或刻意无依据,都算阻塞缺陷)。 + +### 测试用例按 PRD 验收标准写,不只 happy path + +- 每个用例写清前置条件、步骤、期望结果,三项缺一的用例不算 + 有效用例;所有 PRD 验收标准都有对应用例,缺失验收标准对应关系 + 即 QA 不通过(代码 `check_prd_coverage`:只被无效用例覆盖的 + 标准仍算缺失)。 + +### agent 专属六项缺一不算测完,安全签字单独签 + +- 沙箱越界、危险动作审批、长任务超时和取消、断网续跑、 + 模型 503 重试、token / 配额超限,六项缺一不算测完 + (代码 `check_agent_six`)。六项清单与 #244 原文相同, + 归属按 v0.5 已定口径:归单端复核(每端都要过)的是 + 沙箱越界、危险动作审批、token / 配额超限;归跨视口 E2E + (同一任务跨视口行为一致)的是长任务超时和取消、断网续跑、 + 模型 503 重试。 +- 安全检查点 G5 单独签字,不得与 QA 通过合并签字:G5 门里 + 安全检查是独立签字位,QA 通过 ≠ 安全通过。 + +### G5 过关与视口矩阵:全链路口径写死 + +- G5 过关须单元、集成、跨视口一致性、agent 安全测试、长任务测试、 + 性能与安全检查都通过,失败项有归属和复现步骤,严重缺陷未修好前 + 不建议进验收(代码 `check_g5`:缺一门即不通过,未修复的严重 + 缺陷即阻塞,无论各门是否看着全绿)。 +- 视口矩阵须覆盖手机 / 平板 / 桌面全部视口,缺失的视口写明 + 为什么没测;没测不能写成通过:有原因的未测视口算 + “已 accounted、未通过”,无原因的未测视口连 accounted 都不算 + (代码 `check_cross_client_matrix` 把 accounted 与 qa_pass 分开算; + 附注:该代码标识符是 #244 提案原名,是否随视口口径改名为 + 视口版标识符,留给移植轨定,本草稿不擅自改名)。 +- QA 禁止替开发改代码顺手修掉:缺陷由归属线程修,QA 只复现和验证; + 缺陷修完回 G5 回归,回归范围按契约变更影响面定,不是每次全量重测。 + 回归范围的定法可核查:影响面清单里的每一项都要有对应的回归用例, + 没对应回归用例的影响面即列为 uncovered(代码 `plan_regression`: + 只选覆盖影响面的用例,默认不全量;影响面为空不等于“全量”, + 而是选不出用例——缺陷本身所在面必须写进影响面)。 + +### 诚实例外写死,不许冒充 + +- iOS / Android 原生后置占位不进 QA 通过表;Mac 在 Linux 主机上 + 编不出来时,QA 结论写“代码评审 + Web 核心验证”,不写“构建通过”, + 不写“已发布”。编不出来、测不了的端和用例单独列为未测, + 不能算通过(未编译端 verdict 为 `pass` 即违规)。 +- QA 通过不等于用户验收通过,验收清单最后只能用户勾,QA、审计、 + 推回、推倒都不能替用户勾验收(与收紧三的 G6 关口同一规则)。 + +### 与独立审计的关系:两道检查,两个签字位 + +- QA 的视口矩阵与独立审计的独立重跑是两道不同的检查,前者是 + 测试环节跑通业务链路,后者是审计环节独立重跑确切产物, + 两者都不能用对方的报告代替;检查清单里要分别有 QA 矩阵签字 + 与独立审计签字两个独立签字位,缺一即不通过,且两个签字位 + 不能是同一人(代码 `check_signatures`)。 +- 审计 fail 的推回包里要包含 QA 矩阵中失败的链路与用例 + (链路名、用例名、失败项、证据、复现步骤,代码里是推回包的 + `qa_failed_links` / `reaudit_must_rerun_links`);修复后重审 + 要重跑失败链路,而不只是重跑单测;重审清单里失败链路重跑结果 + 单独列出(代码 `check_reaudit_reran_links`:失败链路无重跑结果 + 即不通过,重跑仍失败即重审不通过)。 +- 与自动推回、推倒重做、三条收紧的关系(不得出现两套计数): + QA 矩阵失败本身不单独计数——一轮失败只计一次,不按失败链路条数 + 重复计;独立的 G5 QA 失败(未进审计轮次)走 G5 缺陷回路, + 不直接计入模块失败计数,只有当该轮进入审计 fail 或 QA 验收 + 签字 fail 时,才按来源(audit / acceptance)计入模块失败计数 + 一次;targeted 续轮与推倒重做时 QA 矩阵都要重跑,且重跑范围 + 至少包含推回包里的失败链路(targeted 轮只限失败项与直接相关 + 代码的,QA 重跑范围同样只限失败链路与直接相关链路,不得借机 + 全量重测冒充 targeted)。 + +## QA 校验项(视口版,随本节一并移植,替代 #244 的跨端版 QA 项) + +- QA 全链路走了吗?每端自测三项齐了吗(下限口径,04 附录第 4 项 + 继续有效)?PRD 验收标准全有对应用例吗?agent 专属六项齐了吗? + 视口矩阵覆盖手机 / 平板 / 桌面全部视口了吗,未测视口写明原因了吗? + iOS / Android 后置占位不进 QA 通过表、Mac 未编译结论是不是只写了 + “代码评审 + Web 核心验证”?QA 矩阵签字与独立审计签字两个签字位 + 都在且不是同一人吗?推回包里失败链路齐了吗,重审重跑失败链路了吗? + ## 独立审计与自动推回 本节是审计加固第 3 步的技能机制,与代码 `cloud/audit_pushback.py` diff --git a/cloud/workers.py b/cloud/workers.py index 868b5ff..b17a1cc 100644 --- a/cloud/workers.py +++ b/cloud/workers.py @@ -441,39 +441,34 @@ def _default_kill(pid): def _default_audit(db_path, action, detail): - """默认审计写 audit_log:user_id=system、project_id=""。 + """默认审计:user_id=system、project_id="",经统一审计口写。 - 表不存在时先建(PG 与 SQLite 各一套 DDL,同 - cloud/automations.py 的兜底写法:PG DDL 在 SQLite 下 - 失败即回滚再试 SQLite DDL,PG 适配层下第一条即成功)。 + 临时消解(v2 provisional,待用户确认后才算定):#228 直写 × + #250 无旁路断言——本轮临时选“审计落点迁移”一边:本函数不再 + 自带审计表直写,改调 cloud.auth 的统一审计口(该口写 legacy + 审计行 + 防篡改账本链条目同一对,落点只增链条目、不丢旧行)。 + 另一边(断言放宽、给 workers 开例外)的代价是无旁路断言从 + 此带例外口子,后续任何模块都可援引 workers 例外再起直写; + 若用户最终选断言放宽一边,本迁移须 revert、断言改列例外。 写失败抛给调用方:reap 的杀前审计失败时不杀 (fail-closed),杀后审计失败时只记 audit_after=False。 + 统一审计口在函数内 import:workers 与 auth 同属控制面, + 顶层互引无必要,函数内取已加载模块即可。 """ + # 注意:本文件刻意不再出现审计表的直写语句——#250 的 + # 无旁路断言扫描 cloud/*.py,任何直写都会让它变红。 + from cloud.auth import audit_on_conn conn = _db_connect(str(db_path)) try: - for ddl in ( - "CREATE TABLE IF NOT EXISTS audit_log(" - "id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, " - "user_id TEXT, project_id TEXT, action TEXT, detail TEXT, " - "created_at DOUBLE PRECISION)", - "CREATE TABLE IF NOT EXISTS audit_log(" - "id INTEGER PRIMARY KEY AUTOINCREMENT, " - "user_id TEXT, project_id TEXT, action TEXT, detail TEXT, " - "created_at REAL)"): + try: + audit_on_conn(conn, "system", "", action, detail) + conn.commit() + except Exception: try: - conn.execute(ddl) - conn.execute( - "INSERT INTO audit_log(user_id, project_id, action," - " detail, created_at) VALUES(?, ?, ?, ?, ?)", - ("system", "", action, detail, time.time())) - conn.commit() - return + conn.rollback() except Exception: - try: - conn.rollback() - except Exception: - pass - raise RuntimeError("audit_log write failed") + pass + raise RuntimeError("audit_log write failed") finally: conn.close() @@ -490,8 +485,9 @@ def reap_stale_workers(db_path, stale_seconds=None, local_host=None, - kill 行执行时先用同一 checker 再验一次 pid 已死:若此时 已不死(判定后 pid 被复用等),跳过不杀, outcome="skipped_pid_alive_at_execution",不调 kill_fn。 - - 杀前写审计:audit_fn 未给时走 _default_audit 写 - audit_log(action=worker_pid_kill);审计写失败则不杀, + - 杀前写审计:audit_fn 未给时走 _default_audit,经统一 + 审计口写 audit_log + 账本链(action=worker_pid_kill); + 审计写失败则不杀, outcome="audit_failed"(fail-closed)。 - 杀:kill_fn 未给时走 _default_kill(SIGKILL 只给该 pid 本人);ProcessLookupError 当已死处理,不是失败;其它 diff --git a/deploy/verify-multiuser.py b/deploy/verify-multiuser.py index fc79fa0..69a6b42 100755 --- a/deploy/verify-multiuser.py +++ b/deploy/verify-multiuser.py @@ -33,7 +33,9 @@ 3. viewer 建任务 403;非成员(viewer 对 P2)建任务 403 4. viewer 读 P1 任务成功(200) 5. viewer 读 P2 任务 403(跨项目隔离:P1 成员不是 P2 成员) - 6. maintainer 对 completed 测试任务调 merge 成功(200,状态变 merged) + 6. maintainer 对 completed 测试任务调 merge 成功(200,状态变 merged; + 须附硬门禁全套证据——门禁默认 hard 不变,无证据的合成任务被 409 + 正是门禁预期行为,不是验证脚本可绕过的旧接口) 7. contributor 对 completed 测试任务调 merge 403 8. maintainer 对 queued 测试任务调 merge 409(状态不对) 9. 无 key 调 API 401 @@ -182,9 +184,34 @@ def main(): finally: conn.close() if len(created_task_ids) >= 1: + # 临时消解(v2 provisional,待用户确认后才算定):#240 硬门禁 × + # #263 验证脚本——本轮临时选“验证脚本补门禁证据”一边, + # 门禁默认 hard 一字不动。另一边(门禁给验证场景开口子) + # 的代价是硬门禁出现调用方可走的例外路径,fail-closed + # 语义即被开口;若用户最终选开口子一边,本处证据须随 + # 门禁语义重定,不可两边同时要。 + # 证据说明(合成任务的真实值,不是编造的通过凭证): + # 合成任务未跑模型、未改任何仓库文件,故 files_changed + # 为空清单;owned_dir 沿用既定测试口径的拥有目录写法, + # 仅为使目录检查可判定(空清单对任何拥有目录都通过), + # 不声称改了该目录文件;其余五项按硬门禁全套给齐。 + # 门禁本身不向 GitHub 核实证据真伪(见 merge_gate_hard + # 的诚实边界),本脚本只在本地临时库上验证接口语义。 + gate_evidence = { + "files_changed": [], + "owned_dir": "apps/web", + "branch_protection": "protected", + "ci_status": "success", + "ci_checks": [{"name": "ci", "status": "success"}], + "required_checks": ["ci"], + "contract_version": "v2", + "contract_frozen": True, + "coordinator_approved": True, + "unresolved_review_comments": 0, + } s, merged = req(base, "POST", "/tasks/" + created_task_ids[0] + "/merge", - users["maintainer"][1]) + users["maintainer"][1], gate_evidence) check("maintainer 对 completed 任务调 merge(200 且变 merged)", s == 200 and merged.get("status") == "merged") if len(created_task_ids) >= 2: diff --git a/host.py b/host.py index bc92f75..9527e48 100644 --- a/host.py +++ b/host.py @@ -1407,6 +1407,10 @@ VERIFY_COMMAND_ALLOWLIST = frozenset({ # 验测里常见的最小谓词/输出工具(只读或只写 stdout) "test", "[", "true", "false", "echo", "grep", "cat", "ls", "diff", "wc", "head", "tail", "sleep", + # tr:与 grep/wc/head/tail 同类的 stdin->stdout 纯过滤器, + # 无写文件、无联网、无 -i 原地模式(与 sed 不同,sed 因 -i + # 写文件、awk 因 system() 可执行命令,本轮刻意不补入)。 + "tr", # sleep 是惰性的(只等待,不读写、不执行),且受 verify_timeout # 上限约束;验测里“等一会儿再查文件”(sleep N && test -f) # 是常见写法,旧测试 test_goal_verify_timeout_is_a_verify_failure @@ -1417,7 +1421,20 @@ VERIFY_COMMAND_ALLOWLIST = frozenset({ # pip/pip3(装包取码;python3 -m pip 同理不在首词层拦截,见上文 # “白名单不是沙箱”)、cd/export/time(shell 内建,改 cwd/环境, # 请用 VAR=val cmd 形态或 host 自身的 cwd)。 +# +# 临时消解(v2 provisional,待用户确认后才算定):#256 白名单 × +# #263 旧 shell 语义碰撞——本轮临时选“白名单扩围”一边,见下 +# _VERIFY_SHELL_BUILTINS。另一边(旧测试按新语义更新:管道改 +# grep、exit 3 改 python3 -c sys.exit)的代价是丢掉“任意退出码 +# + 管道过滤器”这组旧 shell 语义的直接证据,且改的是 #263 已 +# 定测试的期望;若用户最终选旧测试更新一边,本处扩围须 revert。 _VERIFY_DENIED_RC = 126 +# 仅有的白名单内建命令:exit。它不是外部程序(simple 路走 +# shell=False 会因找不到可执行文件而抛错),只能走 shell 路; +# 它除终止本条 shell 并返回码外无任何副作用(不读写文件、 +# 不联网、后续段不会执行),故可作段首词,但 classify 须给它 +# 打上 builtin 形态、强制走 shell,绝不能落入 simple/simple-env。 +_VERIFY_SHELL_BUILTINS = frozenset({"exit"}) def _scan_shell_command(command): @@ -1761,6 +1778,11 @@ def classify_verify_command(command, cwd=None): "features": features, "notes": notes} else: notes.append(f"脚本路径未校验(无 cwd):{cmd_word}") + elif cmd_word in _VERIFY_SHELL_BUILTINS: + # 内建命令无 argv 可执行体:打上 builtin 形态,迫使 + # 本函数末尾落入 shell(shell=True 下由 shell 解释), + # 绝不能走 simple/simple-env 的 shell=False 路径。 + features = features | {"builtin"} elif cmd_word not in VERIFY_COMMAND_ALLOWLIST: return {"kind": "denied", "argv": None, "env": None, "reason": f"首词不在验测白名单内:{cmd_word}",