415 lines
17 KiB
Python
415 lines
17 KiB
Python
|
|
# P3 S7 真实冒烟(Agent-N):运维只读诊断(P3-DESIGN §9.2 验收点 A–D)。
|
|||
|
|
# A ops 身份对话 → inbox/ops/ 四文件 + 行级脱敏生效(预埋敏感串断言)
|
|||
|
|
# B P1 草稿语义 + 世界零变更
|
|||
|
|
# C 非 ops 对照(desktop planner 身份)→ 物理不可见:走原话术、run 目录零新增
|
|||
|
|
# D config.apply 双人审批链 —— 已由黄金 H-20 确定性全覆盖(双身份 +
|
|||
|
|
# PARTIALLY_APPROVED/SOD/grant/原子替换/备份/双人审计),冒烟层不重复,
|
|||
|
|
# 此处如实登记覆盖关系。
|
|||
|
|
#
|
|||
|
|
# 身份手法(P3-DESIGN §4.1:角色 = 纯配置零代码):
|
|||
|
|
# - ops 通道:APS_AUTH_ENABLED=0 → bypass_identity 角色含 admin ∈ S7.roles;
|
|||
|
|
# - 非 ops 对照:desktop 许可身份(角色 desktop/planner,∉ S7.roles)。
|
|||
|
|
# 分阶段执行(单命令 300s 预算):setup / ops / control / finalize。
|
|||
|
|
# LLM key 只经 server 子进程环境从仓库根 .env 流入,本脚本不读不打印不落盘。
|
|||
|
|
import hashlib
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import shutil
|
|||
|
|
import subprocess
|
|||
|
|
import sys
|
|||
|
|
import time
|
|||
|
|
import urllib.request
|
|||
|
|
from http.cookiejar import CookieJar
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
HERE = Path(__file__).resolve().parent
|
|||
|
|
REPO = HERE.parents[2]
|
|||
|
|
SITE = HERE / "site5"
|
|||
|
|
APS_HOME = SITE / "aps-home"
|
|||
|
|
DATA = APS_HOME / "data"
|
|||
|
|
PORT = 8395
|
|||
|
|
BASE = f"http://127.0.0.1:{PORT}"
|
|||
|
|
DEVICE_ID = "smoke-n-device-s7-0002"
|
|||
|
|
RESULTS = HERE / "s7_results.json"
|
|||
|
|
MOCK_TENANT = hashlib.sha256(f"{SITE.resolve()}|tenant".encode()).hexdigest()[:32]
|
|||
|
|
MOCK_SECRET = hashlib.sha256(f"{SITE.resolve()}|license".encode()).hexdigest()
|
|||
|
|
|
|||
|
|
QUERY_OPS = "帮我看下服务日志和配置有没有异常"
|
|||
|
|
|
|||
|
|
# 预埋敏感行(脱敏断言用):键值形态 + 长 key 形态各一
|
|||
|
|
SECRET_VALUE = "abc123secretTOKEN99"
|
|||
|
|
KEYISH_VALUE = "A1b2C3d4E5f6G7h8I9j0K1l2"
|
|||
|
|
PLANTED_LOG_LINES = [
|
|||
|
|
"INFO startup ok",
|
|||
|
|
f"ERROR auth failed token={SECRET_VALUE}",
|
|||
|
|
f"WARN upstream api_key={KEYISH_VALUE} retrying",
|
|||
|
|
"INFO done",
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_results() -> dict:
|
|||
|
|
if RESULTS.is_file():
|
|||
|
|
return json.loads(RESULTS.read_text(encoding="utf-8"))
|
|||
|
|
return {"startedAt": time.strftime("%Y-%m-%d %H:%M:%S"), "checks": {}}
|
|||
|
|
|
|||
|
|
|
|||
|
|
R = load_results()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def check(name: str, ok: bool, detail: str = "") -> bool:
|
|||
|
|
R["checks"][name] = {"ok": bool(ok), "detail": detail}
|
|||
|
|
print(f"[{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
|
|||
|
|
return ok
|
|||
|
|
|
|||
|
|
|
|||
|
|
def dump_results() -> None:
|
|||
|
|
RESULTS.write_text(json.dumps(R, ensure_ascii=False, indent=2, default=str),
|
|||
|
|
encoding="utf-8")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def http(opener, method, path, body=None, headers=None, timeout=30):
|
|||
|
|
req = urllib.request.Request(BASE + path, method=method)
|
|||
|
|
for k, v in (headers or {}).items():
|
|||
|
|
req.add_header(k, v)
|
|||
|
|
data = None
|
|||
|
|
if body is not None:
|
|||
|
|
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|||
|
|
req.add_header("Content-Type", "application/json")
|
|||
|
|
return opener.open(req, data=data, timeout=timeout)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def desktop_headers():
|
|||
|
|
return {"x-aps-client": "desktop", "x-aps-device-id": DEVICE_ID}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def bypass_world_path() -> Path:
|
|||
|
|
"""APS_AUTH_ENABLED=0 的 bypass 身份(user_id=1, tenant=platform)世界路径。"""
|
|||
|
|
return DATA / "tenants" / "platform" / "projects" / "personal-1" / "world.json"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def world_fingerprint_of(path: Path) -> str:
|
|||
|
|
sys.path.insert(0, str(REPO))
|
|||
|
|
from server.agent_core.harness import world_fingerprint
|
|||
|
|
return world_fingerprint(json.loads(path.read_text(encoding="utf-8")))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def start_server(env_extra: dict, log_name: str):
|
|||
|
|
env = dict(os.environ)
|
|||
|
|
env.update({
|
|||
|
|
"APS_HOME": str(APS_HOME),
|
|||
|
|
"APS_FEATURES_PATH": str(SITE / "features.json"),
|
|||
|
|
"APS_FALLBACK_HIGHRISK_PATH": str(SITE / "fallback-highrisk.json"),
|
|||
|
|
"APS_WORLD_PATH": str(DATA / "world.json"),
|
|||
|
|
"APS_DB_PATH": str(DATA / "master.db"),
|
|||
|
|
"APS_MOCK_LICENSE_SECRET": MOCK_SECRET,
|
|||
|
|
"APS_MOCK_LICENSE_TENANT_UUID": MOCK_TENANT,
|
|||
|
|
"APS_PORT": str(PORT),
|
|||
|
|
"APS_FALLBACK_MODEL": "aps-fallback/kimi-k2.6",
|
|||
|
|
"APS_FALLBACK_TIMEOUT_SEC": "170",
|
|||
|
|
"NO_PROXY": "127.0.0.1,localhost",
|
|||
|
|
"no_proxy": "127.0.0.1,localhost",
|
|||
|
|
"PYTHONIOENCODING": "utf-8",
|
|||
|
|
})
|
|||
|
|
env.pop("APS_FALLBACK_DIR", None)
|
|||
|
|
env.update(env_extra)
|
|||
|
|
server_log = open(HERE / log_name, "w", encoding="utf-8")
|
|||
|
|
proc = subprocess.Popen(
|
|||
|
|
[sys.executable, "-m", "uvicorn", "server.main:app",
|
|||
|
|
"--host", "127.0.0.1", "--port", str(PORT)],
|
|||
|
|
cwd=str(REPO), env=env, stdout=server_log, stderr=subprocess.STDOUT)
|
|||
|
|
return proc, server_log
|
|||
|
|
|
|||
|
|
|
|||
|
|
def wait_health(proc, seconds=90) -> dict | None:
|
|||
|
|
for _ in range(seconds):
|
|||
|
|
if proc.poll() is not None:
|
|||
|
|
return None
|
|||
|
|
try:
|
|||
|
|
with urllib.request.urlopen(BASE + "/api/health", timeout=3) as r:
|
|||
|
|
return json.loads(r.read().decode("utf-8"))
|
|||
|
|
except Exception:
|
|||
|
|
time.sleep(1)
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def kill_server(proc, server_log) -> None:
|
|||
|
|
if proc.poll() is None:
|
|||
|
|
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"],
|
|||
|
|
capture_output=True, timeout=30, check=False)
|
|||
|
|
try:
|
|||
|
|
proc.wait(timeout=20)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
server_log.close()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def make_opener():
|
|||
|
|
cj = CookieJar()
|
|||
|
|
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_sse(raw: str) -> list[dict]:
|
|||
|
|
events = []
|
|||
|
|
for line in raw.splitlines():
|
|||
|
|
line = line.strip()
|
|||
|
|
if line.startswith("data:"):
|
|||
|
|
try:
|
|||
|
|
events.append(json.loads(line[5:].strip()))
|
|||
|
|
except json.JSONDecodeError:
|
|||
|
|
pass
|
|||
|
|
return events
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _chat(opener, query: str, timeout: int = 190) -> tuple[str, list[dict]]:
|
|||
|
|
resp = http(opener, "POST", "/api/chat", {"text": query},
|
|||
|
|
headers=desktop_headers(), timeout=timeout)
|
|||
|
|
raw = resp.read().decode("utf-8", errors="replace")
|
|||
|
|
return raw, parse_sse(raw)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _robocopy_purge(target: Path) -> None:
|
|||
|
|
empty = target.parent / "_empty_purge"
|
|||
|
|
empty.mkdir(exist_ok=True)
|
|||
|
|
subprocess.run(["robocopy", str(empty), str(target), "/MIR",
|
|||
|
|
"/NFL", "/NDL", "/NJH", "/NJS", "/NP"],
|
|||
|
|
capture_output=True, timeout=120, check=False)
|
|||
|
|
try:
|
|||
|
|
empty.rmdir()
|
|||
|
|
target.rmdir()
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# phase: setup
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def phase_setup() -> int:
|
|||
|
|
if SITE.exists():
|
|||
|
|
for attempt in range(30):
|
|||
|
|
try:
|
|||
|
|
shutil.rmtree(SITE)
|
|||
|
|
break
|
|||
|
|
except OSError:
|
|||
|
|
if attempt == 29:
|
|||
|
|
_robocopy_purge(SITE)
|
|||
|
|
if SITE.exists():
|
|||
|
|
raise
|
|||
|
|
time.sleep(1.0)
|
|||
|
|
DATA.mkdir(parents=True)
|
|||
|
|
shutil.copy(REPO / "server" / "data" / "world.json", DATA / "world.json")
|
|||
|
|
bworld = bypass_world_path()
|
|||
|
|
bworld.parent.mkdir(parents=True)
|
|||
|
|
shutil.copy(REPO / "server" / "data" / "world.json", bworld)
|
|||
|
|
(SITE / "features.json").write_text(
|
|||
|
|
json.dumps({"version": 1, "features": {"fallback": True}}, ensure_ascii=False),
|
|||
|
|
encoding="utf-8")
|
|||
|
|
whitelist = {
|
|||
|
|
"whitelistVersion": 1, "updatedAt": "2026-09-05T09:00:00",
|
|||
|
|
"updatedBy": "smoke-s7",
|
|||
|
|
"scenarios": {
|
|||
|
|
"S4": {"enabled": True,
|
|||
|
|
"intents": ["flex.simulate_due", "flex.compare",
|
|||
|
|
"scenario.compare", "scenario.sensitivity"],
|
|||
|
|
"roles": ["planner", "admin"]},
|
|||
|
|
"S6": {"enabled": True, "intents": ["mes.report"],
|
|||
|
|
"roles": ["planner", "admin"], "maxItemsPerRun": 20},
|
|||
|
|
"S7": {"enabled": True,
|
|||
|
|
"intents": ["agent.fallback.ops.config.apply",
|
|||
|
|
"agent.fallback.policy.update"],
|
|||
|
|
"roles": ["ops", "admin"]}}}
|
|||
|
|
(SITE / "fallback-highrisk.json").write_text(
|
|||
|
|
json.dumps(whitelist, ensure_ascii=False, indent=2), encoding="utf-8")
|
|||
|
|
# 预埋含敏感串的日志(APS_HOME/logs,诊断注入的读取源)
|
|||
|
|
log_dir = APS_HOME / "logs"
|
|||
|
|
log_dir.mkdir(parents=True)
|
|||
|
|
(log_dir / "server.log").write_text(
|
|||
|
|
"\n".join(PLANTED_LOG_LINES) + "\n", encoding="utf-8")
|
|||
|
|
R.clear()
|
|||
|
|
R.update({"startedAt": time.strftime("%Y-%m-%d %H:%M:%S"), "checks": {},
|
|||
|
|
"site": str(SITE), "bypassWorld": str(bworld), "query": QUERY_OPS})
|
|||
|
|
check("setup.site_ready", bworld.is_file()
|
|||
|
|
and (log_dir / "server.log").is_file())
|
|||
|
|
dump_results()
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# phase: ops —— 运维身份只读诊断(验收点 A/B)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def phase_ops() -> int:
|
|||
|
|
bworld = bypass_world_path()
|
|||
|
|
fp_before = world_fingerprint_of(bworld) if bworld.is_file() else None
|
|||
|
|
proc, server_log = start_server({"APS_AUTH_ENABLED": "0"},
|
|||
|
|
"server_s7_ops.log")
|
|||
|
|
try:
|
|||
|
|
health = wait_health(proc)
|
|||
|
|
if not health:
|
|||
|
|
check("A0.ops_server_health", False, "server 90s 内未就绪(见 server_s7_ops.log)")
|
|||
|
|
dump_results()
|
|||
|
|
return 1
|
|||
|
|
check("A0.ops_server_health", True)
|
|||
|
|
opener = make_opener()
|
|||
|
|
|
|||
|
|
fb_root = DATA / "fallback"
|
|||
|
|
runs_before = {p.name for p in fb_root.glob("fb-*")} if fb_root.is_dir() else set()
|
|||
|
|
t0 = time.monotonic()
|
|||
|
|
raw, events = _chat(opener, QUERY_OPS)
|
|||
|
|
R["chatElapsedSec"] = round(time.monotonic() - t0, 1)
|
|||
|
|
(HERE / "chat_s7_ops.sse").write_text(raw, encoding="utf-8")
|
|||
|
|
reply_text = "".join(e.get("text", "") for e in events if e.get("type") == "token")
|
|||
|
|
(HERE / "reply_s7_ops.txt").write_text(reply_text, encoding="utf-8")
|
|||
|
|
R["replyHead"] = reply_text[:600]
|
|||
|
|
intent_ev = next((e for e in events if e.get("type") == "intent"), None)
|
|||
|
|
intent_name = ((intent_ev or {}).get("intent") or {}).get("intent") \
|
|||
|
|
if isinstance((intent_ev or {}).get("intent"), dict) \
|
|||
|
|
else (intent_ev or {}).get("intent")
|
|||
|
|
check("A1.intent_lands_assistant_reply", intent_name == "assistant.reply",
|
|||
|
|
f"intent={intent_name}")
|
|||
|
|
|
|||
|
|
runs_after = {p.name for p in fb_root.glob("fb-*")} if fb_root.is_dir() else set()
|
|||
|
|
new_runs = sorted(runs_after - runs_before)
|
|||
|
|
run_dir = fb_root / new_runs[-1] if new_runs else None
|
|||
|
|
R["opsRunDir"] = str(run_dir) if run_dir else None
|
|||
|
|
|
|||
|
|
# 验收点 A:inbox/ops/ 四文件 + 行级脱敏
|
|||
|
|
ops_dir = run_dir / "inbox" / "ops" if run_dir else None
|
|||
|
|
four = ("logs-tail.md", "config-snapshot.md", "health.md", "integrations.md")
|
|||
|
|
files_ok = bool(ops_dir) and all((ops_dir / n).is_file() for n in four)
|
|||
|
|
logs_tail = (ops_dir / "logs-tail.md").read_text(encoding="utf-8") \
|
|||
|
|
if ops_dir and (ops_dir / "logs-tail.md").is_file() else ""
|
|||
|
|
snapshot = (ops_dir / "config-snapshot.md").read_text(encoding="utf-8") \
|
|||
|
|
if ops_dir and (ops_dir / "config-snapshot.md").is_file() else ""
|
|||
|
|
R["logsTailExcerpt"] = logs_tail[:500]
|
|||
|
|
check("A2.ops_four_files", files_ok,
|
|||
|
|
f"ops_dir={ops_dir} files={[n for n in four if ops_dir and (ops_dir / n).is_file()]}")
|
|||
|
|
check("A3.redaction_effective",
|
|||
|
|
"***REDACTED***" in logs_tail
|
|||
|
|
and SECRET_VALUE not in logs_tail
|
|||
|
|
and KEYISH_VALUE not in logs_tail,
|
|||
|
|
f"REDACTED 出现={'***REDACTED***' in logs_tail};"
|
|||
|
|
f"敏感串泄漏={SECRET_VALUE in logs_tail or KEYISH_VALUE in logs_tail}")
|
|||
|
|
check("A4.config_snapshot_projection",
|
|||
|
|
"features.json" in snapshot and "fallback-highrisk.json" in snapshot,
|
|||
|
|
"features 全文 + 白名单裁决投影均在")
|
|||
|
|
# 验收点 B:P1 草稿语义 + 世界零变更
|
|||
|
|
check("B1.draft_semantics", "草稿" in reply_text,
|
|||
|
|
reply_text.replace("\n", " ")[:150])
|
|||
|
|
fp_after = world_fingerprint_of(bworld) if bworld.is_file() else None
|
|||
|
|
check("B2.world_unchanged", fp_before is not None and fp_before == fp_after,
|
|||
|
|
f"fp {str(fp_before)[:12]} == {str(fp_after)[:12]}")
|
|||
|
|
# 诊断报告引用校验(calls.jsonl 凭证链存在即 P1 围墙内读取发生)
|
|||
|
|
calls = run_dir / "calls.jsonl" if run_dir else None
|
|||
|
|
n_calls = 0
|
|||
|
|
if calls and calls.is_file():
|
|||
|
|
n_calls = len([x for x in calls.read_text(encoding="utf-8").splitlines()
|
|||
|
|
if x.strip()])
|
|||
|
|
R["callsCount"] = n_calls
|
|||
|
|
check("B3.pi_read_in_wall", n_calls > 0,
|
|||
|
|
f"calls.jsonl {n_calls} 行(fs_read 围墙内读取)")
|
|||
|
|
finally:
|
|||
|
|
kill_server(proc, server_log)
|
|||
|
|
dump_results()
|
|||
|
|
failed = [k for k, v in R["checks"].items() if not v["ok"]]
|
|||
|
|
return 0 if not failed else 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# phase: control —— 非 ops 对照(desktop planner 身份 → 物理不可见,验收点 C)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def phase_control() -> int:
|
|||
|
|
proc, server_log = start_server({"APS_LICENSE_PROVIDER": "mock"},
|
|||
|
|
"server_s7_control.log")
|
|||
|
|
try:
|
|||
|
|
health = wait_health(proc)
|
|||
|
|
if not health:
|
|||
|
|
check("C0.control_server_health", False,
|
|||
|
|
"server 90s 内未就绪(见 server_s7_control.log)")
|
|||
|
|
dump_results()
|
|||
|
|
return 1
|
|||
|
|
opener = make_opener()
|
|||
|
|
resp = http(opener, "POST", "/api/auth/license/activate",
|
|||
|
|
{"code": "APS-DAY-DEMO", "deviceId": DEVICE_ID},
|
|||
|
|
headers=desktop_headers())
|
|||
|
|
resp.read()
|
|||
|
|
|
|||
|
|
fb_root = DATA / "fallback"
|
|||
|
|
runs_before = {p.name for p in fb_root.glob("fb-*")} if fb_root.is_dir() else set()
|
|||
|
|
raw, events = _chat(opener, QUERY_OPS, timeout=120)
|
|||
|
|
(HERE / "chat_s7_control.sse").write_text(raw, encoding="utf-8")
|
|||
|
|
reply_text = "".join(e.get("text", "") for e in events if e.get("type") == "token")
|
|||
|
|
(HERE / "reply_s7_control.txt").write_text(reply_text, encoding="utf-8")
|
|||
|
|
R["controlReplyHead"] = reply_text[:400]
|
|||
|
|
runs_after = {p.name for p in fb_root.glob("fb-*")} if fb_root.is_dir() else set()
|
|||
|
|
new_runs = sorted(runs_after - runs_before)
|
|||
|
|
# 物理不可见:零新增 run 目录 + 回复不含 S7 兜底标记(走原话术路径)
|
|||
|
|
check("C.non_ops_invisible",
|
|||
|
|
not new_runs and "[智能兜底" not in reply_text,
|
|||
|
|
f"新增 run={new_runs};兜底标记={'[智能兜底' in reply_text}")
|
|||
|
|
finally:
|
|||
|
|
kill_server(proc, server_log)
|
|||
|
|
dump_results()
|
|||
|
|
failed = [k for k, v in R["checks"].items() if not v["ok"]]
|
|||
|
|
return 0 if not failed else 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# phase: finalize —— 收尾校验 + 汇总
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def phase_finalize() -> int:
|
|||
|
|
time.sleep(2)
|
|||
|
|
port_free = False
|
|||
|
|
try:
|
|||
|
|
urllib.request.urlopen(BASE + "/api/health", timeout=3)
|
|||
|
|
except Exception:
|
|||
|
|
port_free = True
|
|||
|
|
wmi = ""
|
|||
|
|
ps_exe = shutil.which("powershell.exe") or shutil.which("powershell") or "powershell.exe"
|
|||
|
|
try:
|
|||
|
|
wmi = subprocess.run(
|
|||
|
|
[ps_exe, "-NoProfile", "-Command",
|
|||
|
|
("Get-CimInstance Win32_Process | Where-Object "
|
|||
|
|
"{$_.CommandLine -like '*pi-coding-agent*cli.js*' "
|
|||
|
|
"-and $_.CommandLine -notlike '*Get-CimInstance*'} "
|
|||
|
|
"| Select-Object -ExpandProperty ProcessId")],
|
|||
|
|
capture_output=True, text=True, timeout=60, check=False).stdout.strip()
|
|||
|
|
except Exception as exc:
|
|||
|
|
R["piResidualCheckError"] = str(exc)
|
|||
|
|
R["piResidualPids"] = [x for x in wmi.splitlines() if x.strip()]
|
|||
|
|
git = subprocess.run(["git", "status", "--short", "server/data"],
|
|||
|
|
cwd=str(REPO), capture_output=True, text=True, timeout=30,
|
|||
|
|
check=False).stdout.strip()
|
|||
|
|
R["gitServerData"] = git or "(clean)"
|
|||
|
|
check("H.cleanup", port_free and not R["piResidualPids"] and not git,
|
|||
|
|
f"端口释放={port_free} pi 残留={R['piResidualPids']} git={git or 'clean'}")
|
|||
|
|
# 验收点 D 覆盖关系登记(黄金 H-20 确定性全覆盖,冒烟层不重复)
|
|||
|
|
R["acceptD"] = {"coveredBy": "tests/golden/test_fallback_highrisk.py::"
|
|||
|
|
"test_s7_config_apply_p3_dual_approval_flow",
|
|||
|
|
"note": "双人审批链(PARTIALLY_APPROVED→SOD_DENIED→异人二批→"
|
|||
|
|
"executionGrant→原子替换+备份+双人审计)确定性锁定"}
|
|||
|
|
check("D.covered_by_golden_H20", True, "见 acceptD 登记")
|
|||
|
|
|
|||
|
|
dump_results()
|
|||
|
|
failed = [k for k, v in R["checks"].items() if not v["ok"]]
|
|||
|
|
total = len(R["checks"])
|
|||
|
|
print(json.dumps({"passed": total - len(failed), "total": total,
|
|||
|
|
"failed": failed}, ensure_ascii=False, indent=2))
|
|||
|
|
return 0 if not failed else 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
PHASES = {"setup": phase_setup, "ops": phase_ops, "control": phase_control,
|
|||
|
|
"finalize": phase_finalize}
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
if len(sys.argv) != 2 or sys.argv[1] not in PHASES:
|
|||
|
|
print(f"usage: {sys.argv[0]} <{'|'.join(PHASES)}>")
|
|||
|
|
sys.exit(2)
|
|||
|
|
sys.exit(PHASES[sys.argv[1]]())
|