#!/usr/bin/env bash # 15-criterion4-live.sh —— 契约 §6 **判据④** 的活环境验收门 # # 判据④ 原文:≥2 台物理机的 Agent **在同一房间互见**,且各自被 @ 时命中。 # # 为什么值得单独一个脚本:判据④ 是**阶段 1 的唯一硬阻塞**,而它此前只能肉眼看房间 - # 谁在跑、谁被命中、有没有串台,全靠人记。本脚本把它变成"跑一次、看一张表、退出码说话"。 # # 用法(凭据只走环境变量,不落盘、不打印): # MATRIX_BASE_URL=https://imbot.apescale.com \ # MATRIX_ACCESS_TOKEN=<本节点 bot token> \ # BRIDGE_ROOM='!xxxx:matrix.113.249.102.nip.io' \ # TARGET_A='@tutti-hermes:matrix.113.249.102.nip.io' \ # TARGET_B='@tutti-pi:matrix.113.249.102.nip.io' \ # BRIDGE_A='@bridge3:...' BRIDGE_B='@bridge2:...' \ # NODE_NAME=mac13 \ # bash scripts/15-criterion4-live.sh # # bash scripts/15-criterion4-live.sh --selftest # 离线自测(含"门必须会红"的对照) # bash scripts/15-criterion4-live.sh --pair a.log b.log # 两台机各跑一次后, 机器判定"两台物理机" # # 退出码:0 = 判据④ 通过 | 1 = 断言失败(门会红)| 2 = 前置缺失/取数不可信(拒绝判读) # 边界:只向 BRIDGE_ROOM 白名单房间发消息(R-8);不发任何其它房间;不打印任何凭据值。 set -uo pipefail BASE="${MATRIX_BASE_URL:-}" TOKEN="${MATRIX_ACCESS_TOKEN:-}" ROOM="${BRIDGE_ROOM:-}" TA="${TARGET_A:-}" TB="${TARGET_B:-}" BA="${BRIDGE_A:-}" BB="${BRIDGE_B:-}" NODE="${NODE_NAME:-$(hostname -s 2>/dev/null || echo unknown)}" WAIT="${P4_WAIT:-45}" # 等回执链的总超时(秒) NEG_WAIT="${P4_NEG_WAIT:-8}" # 阴性对照观察窗(秒) pass=0; fail=0; skip=0 ok() { printf ' \033[32m✅\033[0m %s\n' "$*"; pass=$((pass+1)); } bad() { printf ' \033[31m❌\033[0m %s\n' "$*"; fail=$((fail+1)); } skp() { printf ' \033[33m⏭\033[0m %s\n' "$*"; skip=$((skip+1)); } sec() { printf '\n\033[1m── %s\033[0m\n' "$*"; } TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT FETCH_FAIL=0 # 取数失败 ⇒ 拒绝判读(exit 2), 不与"断言失败(exit 1)"混为一谈 enc() { python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$1"; } api() { # api [json-file] -> stdout; 返回 curl 退出码 local m="$1" p="$2" f="${3:-}" if [ -n "$f" ]; then curl -sS --max-time 20 -X "$m" -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' --data-binary "@$f" "$BASE$p" else curl -sS --max-time 20 -X "$m" -H "Authorization: Bearer $TOKEN" "$BASE$p" fi } need() { [ -n "${1:-}" ] || { echo "🔴 缺少环境变量 $2 —— 判读不可信,拒绝判读(exit 2)" >&2; return 2; }; } preflight() { sec "前置(只读)" need "$BASE" MATRIX_BASE_URL; need "$TOKEN" MATRIX_ACCESS_TOKEN need "$ROOM" BRIDGE_ROOM; need "$TA" TARGET_A; need "$TB" TARGET_B local r r="$(api GET /_matrix/client/v3/account/whoami)" || { bad "whoami 请求失败(curl)"; return 2; } ME="$(printf '%s' "$r" | python3 -c " import json,sys d=json.load(sys.stdin) print(d.get('user_id','') if isinstance(d,dict) else '')")" [ -n "$ME" ] && ok "桥身份可认证: $ME" || { bad "whoami 拿不到 user_id(令牌失效?) —— 判读不可信"; return 2; } r="$(api GET /_matrix/client/v3/joined_rooms)" printf '%s' "$r" | python3 -c " import json,sys d=json.load(sys.stdin) sys.exit(0 if isinstance(d,dict) and sys.argv[1] in (d.get('joined_rooms') or []) else 1)" "$ROOM" \ && ok "已加入白名单房间 $ROOM" || { bad "该 token 未加入 ${ROOM}(或 joined_rooms 取数失败)"; return 2; } r="$(api GET "/_matrix/client/v3/rooms/$(enc "$ROOM")/joined_members")" MISS="$(printf '%s' "$r" | python3 -c " import json,sys d=json.load(sys.stdin) if not isinstance(d,dict) or 'joined' not in d: print('APIERR'); sys.exit(0) j=set(d['joined']) print(','.join(t for t in sys.argv[1:3] if t not in j))" "$TA" "$TB")" case "$MISS" in APIERR) bad "joined_members 取数失败 —— 判读不可信"; return 2;; "") ok "两个 target 都在房间里(R-7 前置): $(basename "${TA%%:*}") / $(basename "${TB%%:*}")";; *) bad "房间缺 target: $MISS —— 判据④ 前置不满足(先做 T-1.10 步骤 1)"; return 2;; esac return 0 } send() { # send -> 打印 event_id local text="$1" f="$TMP/send.json" txn="p4-$$-$RANDOM" python3 -c " import json,sys json.dump({'msgtype':'m.text','body':sys.argv[1]}, open(sys.argv[2],'w',encoding='utf-8'), ensure_ascii=False)" \ "$text" "$f" api PUT "/_matrix/client/v3/rooms/$(enc "$ROOM")/send/m.room.message/$(enc "$txn")" "$f" \ | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('event_id','') if isinstance(d,dict) else '')" } receipts() { # receipts <根事件id> -> 每行 " "; 取数失败输出 __FETCH_FAILED__ 哨兵 local root="$1" f="$TMP/msgs.json" api GET "/_matrix/client/v3/rooms/$(enc "$ROOM")/messages?dir=b&limit=100" > "$f" python3 - "$root" "$f" <<'PY' import json, sys root, path = sys.argv[1], sys.argv[2] # 缺陷L(workbuddy-mac13 报): 取数失败**不许**与"确实 0 条"同形 —— 阴性对照的判据就是"0 条", # 吞掉错误会让它 fail-open(403/401/网络故障都会判绿)。故用哨兵, 由消费处拒绝判读。 try: d = json.load(open(path, encoding="utf-8")) except Exception: print("__FETCH_FAILED__"); sys.exit(0) if not isinstance(d, dict) or "chunk" not in d: print("__FETCH_FAILED__"); sys.exit(0) rows = [] for ev in (d.get('chunk') or []): c = ev.get('content') or {} rel = c.get('m.relates_to') or {} if rel.get('rel_type') != 'm.thread' or rel.get('event_id') != root: continue st = c.get('status') or c.get('tutti_status') if st: rows.append((ev.get('origin_server_ts', 0), ev.get('sender', '?'), st)) for _, sender, st in sorted(rows): # 按服务端时间戳正序, 不信服务端返回顺序 print(f"{sender} {st}") PY } expect_chain() { # expect_chain <标签> <根事件id> <期望 sender(可空)> <禁止 sender(可空)> local label="$1" root="$2" want="$3" forbid="$4" got="" local deadline=$(( $(date +%s) + WAIT )) while :; do got="$(receipts "$root")" if printf '%s' "$got" | grep -q '__FETCH_FAILED__'; then bad "$label 取数失败(/messages) —— 判读不可信, 拒绝给结论"; FETCH_FAIL=1; return 2 fi printf '%s' "$got" | grep -qE ' (succeeded|failed|timeout)$' && break [ "$(date +%s)" -ge "$deadline" ] && break sleep 2 done local states senders states="$(printf '%s' "$got" | awk 'NF{print $2}' | paste -sd' ' - 2>/dev/null)" senders="$(printf '%s' "$got" | awk 'NF{print $1}' | sort -u | paste -sd' ' - 2>/dev/null)" printf ' %s 回执: [%s] 来自: [%s]\n' "$label" "${states:-无}" "${senders:-无}" # 1) 必须是 accepted -> running -> 终态, 且 running 不许缺(R-3) printf '%s' "$states" | grep -q "accepted" || { bad "$label 缺 accepted"; return 1; } printf '%s' "$states" | grep -q "running" || { bad "$label 缺 running(R-3: 禁止静默完成)"; return 1; } printf '%s' "$states" | grep -qE "succeeded|failed|timeout" || { bad "$label 未达终态(超时 ${WAIT}s)"; return 1; } python3 -c " import sys seq=sys.argv[1].split() # 【dsh@mac13】R-1 与 R-3/R-4 的**表面冲突**:契约 R-1 明写"重复投递返回上一次的回执、attempt 递增", # 于是合法序列可能是 `accepted accepted running succeeded`。老断言要求 accepted 只能在第 0 位, # 会把这种**合法重放**误判成顺序错。改判为:首条必须是 accepted;accepted 不得晚于 running; # running 至多一条;终态后不得再有回执。(真链路实测出现过 attempt 1→2 两条 accepted。) first_run = next((i for i, s in enumerate(seq) if s == 'running'), None) first_acc = next((i for i, s in enumerate(seq) if s == 'accepted'), None) bad = [] if first_acc != 0: bad.append('首条不是 accepted') if any(s == 'accepted' and first_run is not None and i > first_run for i, s in enumerate(seq)): bad.append('accepted 出现在 running 之后') if seq.count('running') > 1: bad.append('running 出现多次') term=[i for i,s in enumerate(seq) if s in ('succeeded','failed','timeout')] assert not bad, 'accepted/running 顺序不合 R-3/R-4: '+'; '.join(bad)+' | '+' '.join(seq) assert not term or term[0]==len(seq)-1, '终态后仍有回执: '+sys.argv[1]" "$states" \ || { bad "$label 回执顺序不合 R-3/R-4"; return 1; } ok "$label 回执链完整(到达终态, R-3): $states" # 【dsh@mac13】补第二档判定:上面只保证"出现了终态"(R-3 不许静默完成), # 但 **A2 的真实语义是"结果回到房间"** ⇒ 失败/超时不该与成功同等计为通过。 # 实测依据:接真 agent 后出现过 `[accepted running failed]` 被旧门判 ✅。 if printf '%s' "$states" | grep -qE " (failed|timeout)$"; then bad "$label 终态是失败/超时(A2 未达成): $states" return 1 fi ok "$label 终态为成功(A2 结果已回到房间)" # 2) 归属: 只应由服务该 target 的桥回执(R-7 一 target 一账号, 不许串台) if [ -n "$want" ]; then [ "$senders" = "$want" ] && ok "$label 回执归属正确: $want" \ || { bad "$label 期望来自 $want, 实际 [$senders]"; return 1; } fi if [ -n "$forbid" ]; then case " $senders " in *" $forbid "*) bad "$label 出现**另一节点**的桥($forbid)回执 ⇒ 串台(R-7 违反)"; return 1;; *) ok "$label 未串台(无 $forbid 回执)";; esac fi return 0 } probe() { # probe