#!/bin/bash # gitea-arm-check.sh — 核對 leo 有沒有在「該請求指定的那張票」上回覆某個 ARM 請求的代碼 # # 用法: # scripts/gitea-arm-check.sh # 掃描所有還沒過期的本地待核請求(可能橫跨多張票) # scripts/gitea-arm-check.sh ARM-xxxxxxxx # 只核對這一組 # # 判定放行的三個條件,**缺一不可**: # ① 這組 nonce 從沒被消耗過(防重放)、且還沒過期(範圍與時效)——本地就能判,不必碰網路 # ② 留言作者的 login 精確等於 "Leo"(不是 id、不是顯示名——那些會變) # ③ 留言內文含這組 nonce,且晚於「機器貼出請求」的那則留言、且出現在**該請求貼出時指定的那張票** # (票號隨每個請求存在本地待核檔的 `issue` 欄位裡,2026-08-16 起不再是寫死的單一頻道票, # 見 lib/gitea-arm-common.sh 檔頭;別的票再怎麼有 Leo 回過相似字串的留言都不算數, # 因為根本不會被拿去查——每個 nonce 只查它自己那張票) # # 成功:印 "ARMED: "、把 nonce 標記已消耗、刪掉本地待核檔、exit 0(單次用完即失效) # 失敗(沒有/過期/被消耗過/Gitea 打不到/回應解不出來):印原因到 stderr、exit 1 # ——**fail-closed**:任何看不懂的狀況都當失敗,不放行。 # # 🔴 順序刻意是「先本地過濾,才打網路」: # 過期/已消耗這兩種本地就能判定,**不該因為 Gitea 打不到而連本地清理都做不了** # (早期版本把網路呼叫放最前面,測試才抓到:token 失效時,過期的待核檔永遠不會被清掉, # 因為程式在走到「清掉它」那行之前就已經因為 fail-closed 提前 exit 了)。 # # 🔴 這支只在「機器需要解閘的當下」被閘呼叫一次——不是排程輪詢(D20 紅線)。 # 呼叫方一次只打一發 GET **對每一張還有待核請求的票**(不同票各打一次, # 同一張票不管上面掛幾組 nonce 只打一次、共用回應),不建任何迴圈/背景行程來等 leo 回覆。 set -uo pipefail SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" # shellcheck source=lib/gitea-arm-common.sh . "$SCRIPT_DIR/lib/gitea-arm-common.sh" STATE_DIR=$(gitea_arm_state_dir) PENDING_DIR="$STATE_DIR/pending" # ── --peek:只看有沒有核准,**不消耗**(leo 2026-08-16 立)─────────────────── # # 🔴 為什麼要有這個模式(同日實撞,而且是「發生過沒解決,下次又踩」的那種): # 總管為了「先確認 leo 貼了碼」單獨跑了一次本檔 ⇒ **那一次就把核准消耗掉了** # ⇒ 真正要用的 gitea-arm-to-github-armed.sh 再來拿時已經沒有待核請求 # ⇒ **leo 的動作被浪費一次,他得再貼一次。** # # leo 原話:「這不是問題,問題是**已經發生好幾次了,你發生過以後沒有解決,下次還是碰到**。 # 你可以註記只能一次,要不然**改成可以一次檢查一次發射**。」 # ⇒ 選後者:**檢查不消耗,發射才消耗。** # # ⛔ 防重放在 peek 模式下**照樣生效**——已被消耗過的 nonce peek 也不會回 ARMED, # 否則 peek 就變成繞過重放保護的後門。 # 🔴 2026-08-16 第二輪(leo 當場點破,同日第一輪的修是錯方向): # leo 原話:「**你檢查是否有貼可以用查看 issue 的 API 不應該會用掉,所以你查看的方式不對。**」 # ⇒ 讀留言是 GET,本來就不該有副作用。「消耗」是本檔自己黏在讀取上的**本地**記帳。 # ⇒ 所以正解不是「加一個 --peek 選配」(第一輪那樣做=預設仍然咬人, # 只有記得加旗標的人不被咬),而是**把預設反過來**: # # 不帶旗標 = 只讀,永不消耗 ← 任何人手動探一眼都安全 # --consume = 真的要發射,才消耗 ← 只有「放行那一步」自己會帶 # # 這也正是 mistakes.md:237 兩天前就寫著的「待修:check 應該要有唯讀模式」, # 以及 231 行「想知道 leo 貼了沒 → 讀票,不要用 check 去探」。 # **寫下來了,08-16 還是照撞一次** ⇒ 所以改成機制上撞不到,不是再寫一條提醒。 # # ⛔ 防重放不變:已消耗過的 nonce 在唯讀模式下**照樣不回 ARMED**, # 否則唯讀就變成繞過重放保護的後門。 # # 🐛 第一輪的實際 bug(也在這裡一起修):舊碼 `WANT_NONCE="${1:-}"` 會把 `--peek` # 當成「要篩的 nonce」吃掉 ⇒ 一個都不匹配 ⇒ 靜靜回「沒有還活著的待核請求」。 # ⇒ 旗標與 nonce 現在在**同一個迴圈**裡分開解析,旗標永遠不會被當成 nonce。 CONSUME=0 WANT_NONCE="" for _a in "$@"; do case "$_a" in --consume) CONSUME=1 ;; --peek) : ;; # 相容舊呼叫;現在唯讀本來就是預設,這個旗標等同不做事 -*) echo "❌ 不認得的參數:$_a(只接受 --consume/--peek/)" >&2; exit 1 ;; *) [ -z "$WANT_NONCE" ] && WANT_NONCE="$_a" ;; esac done CONSUMED_LOG=$(gitea_arm_consumed_log) NOW=$(date +%s) shopt -s nullglob PENDING_FILES=("$PENDING_DIR"/*.json) shopt -u nullglob if [ "${#PENDING_FILES[@]}" -eq 0 ]; then echo "沒有待核的 ARM 請求(先跑 scripts/gitea-arm-request.sh)" >&2 exit 1 fi # ── 第一遍:純本地過濾,完全不碰網路 ────────────────────────────────── # 篩出「還活著、還沒被用過」的候選;順手清掉壞掉/過期/已消耗的本地檔。 # 🔴 不用 `declare -A`(本機 /bin/bash 是 3.2,沒有關聯陣列)—— # 改用一個 TSV 暫存檔存 nonce/issue/request_created_at/mission,逐行讀。 # 每個請求各自帶著自己的票號(issue 欄位,2026-08-16 起不再是單一頻道票)。 ELIGIBLE_TSV=$(mktemp) RESP_DIR=$(mktemp -d) trap 'rm -f "$ELIGIBLE_TSV"; rm -rf "$RESP_DIR"' EXIT for PF in "${PENDING_FILES[@]}"; do [ -f "$PF" ] || continue NONCE=$(jq -r '.nonce // empty' "$PF" 2>/dev/null) if [ -z "$NONCE" ]; then echo "⚠️ 略過壞掉的待核檔:$PF" >&2 continue fi if [ -n "$WANT_NONCE" ] && [ "$NONCE" != "$WANT_NONCE" ]; then continue fi MISSION=$(jq -r '.mission // empty' "$PF" 2>/dev/null) ISSUE=$(jq -r '.issue // empty' "$PF" 2>/dev/null) # 2026-08-16:請求可能貼在同 org 的別的 repo(出貨票在 arcrun-rag)。 # 舊記錄沒有 .repo 這欄 → 沿用預設 InkStoneCo,不破壞相容。 _PR=$(jq -r '.repo // empty' "$PF" 2>/dev/null) [ -n "$_PR" ] && { gitea_arm_set_repo "$_PR" || continue; } EXPIRES_AT=$(jq -r '.expires_at // empty' "$PF" 2>/dev/null) REQUEST_CREATED_AT=$(jq -r '.request_created_at // empty' "$PF" 2>/dev/null) if ! gitea_arm_valid_issue "$ISSUE"; then echo "⚠️ 略過壞掉的待核檔(issue 缺失或非數字,$PF)——這份請求檔是舊版格式或損毀,不猜票號" >&2 continue fi case "$EXPIRES_AT" in ''|*[!0-9]*) echo "⚠️ 略過壞掉的待核檔(expires_at 非數字):$PF" >&2; continue;; esac if [ -z "$REQUEST_CREATED_AT" ]; then echo "⚠️ 略過壞掉的待核檔(缺 request_created_at):$PF" >&2 continue fi # 過期:清掉本地待核檔(沒被用過,不算消耗),繼續看下一個 if [ "$NOW" -ge "$EXPIRES_AT" ]; then echo "⌛ $NONCE(#$ISSUE)已過期,清掉待核請求" >&2 rm -f "$PF" continue fi # 防重放:這個 nonce 先前是否已被消耗過(即使 Gitea 上那則留言還在,也不能再解一次) if [ -f "$CONSUMED_LOG" ] && grep -qF "$(printf '%s\t' "$NONCE")" "$CONSUMED_LOG" 2>/dev/null; then echo "🚫 $NONCE(#$ISSUE)已經被用過一次,不能重放" >&2 rm -f "$PF" continue fi printf '%s\t%s\t%s\t%s\n' "$NONCE" "$ISSUE" "$REQUEST_CREATED_AT" "$MISSION" >> "$ELIGIBLE_TSV" done if [ ! -s "$ELIGIBLE_TSV" ]; then echo "沒有還活著、還沒用過的待核請求 → 不放行" >&2 exit 1 fi TOKEN=$(gitea_arm_token) || { echo "❌ 讀不到 GITEA_TOKEN_CLAUDE_CODE → fail-closed,不放行" >&2; exit 1; } # ── 第二遍:對每一張出現在 ELIGIBLE_TSV 的票各打一次 GET(同票只打一次,跨票各自獨立)── for ISSUE in $(cut -f2 "$ELIGIBLE_TSV" | sort -u); do RESP=$(curl -s -w '\n%{http_code}' --max-time 15 \ -H "Authorization: token $TOKEN" \ "$GITEA_ARM_API/repos/$GITEA_ARM_OWNER/$GITEA_ARM_REPO/issues/$ISSUE/comments?limit=100") CURL_RC=$? HTTP_CODE=$(printf '%s' "$RESP" | tail -1) RESP_BODY=$(printf '%s' "$RESP" | sed '$d') if [ "$CURL_RC" -ne 0 ] || [ "$HTTP_CODE" != "200" ]; then echo "❌ #$ISSUE:Gitea 打不到/回應非 200(curl_rc=$CURL_RC, http=$HTTP_CODE)→ 該票所有待核請求 fail-closed" >&2 continue fi if ! printf '%s' "$RESP_BODY" | jq -e 'type == "array"' >/dev/null 2>&1; then echo "❌ #$ISSUE:Gitea 回應不是預期的陣列格式 → 該票所有待核請求 fail-closed" >&2 continue fi printf '%s' "$RESP_BODY" > "$RESP_DIR/$ISSUE.json" done # ── 第三遍:逐一比對,每組 nonce 只看它自己那張票抓回來的留言 ────────────── RESULT=1 while IFS=$'\t' read -r NONCE ISSUE AFTER MISSION; do [ -n "$NONCE" ] || continue RESP_FILE="$RESP_DIR/$ISSUE.json" if [ ! -f "$RESP_FILE" ]; then echo "…$NONCE(#$ISSUE):該票打不到,不放行" >&2 continue fi # 核心判定:作者精確是 Leo、內文含這組 nonce、時間晚於請求留言—— # 且比對只在 $RESP_FILE 這一張票的留言範圍內,不會混到別張票 MATCH=$(jq -r \ --arg nonce "$NONCE" --arg after "$AFTER" --arg who "$GITEA_ARM_APPROVER_LOGIN" ' [ .[] | select(.user.login == $who) | select(.body | contains($nonce)) | select(.created_at > $after) ] | sort_by(.created_at) | .[0].id // empty ' "$RESP_FILE") PF="$PENDING_DIR/$NONCE.json" if [ -n "$MATCH" ]; then if [ "$CONSUME" -eq 1 ]; then printf '%s\t%s\t%s\t#%s\n' "$NONCE" "$(date '+%Y-%m-%d %H:%M:%S')" "$MISSION" "$ISSUE" >> "$CONSUMED_LOG" rm -f "$PF" echo "ARMED: $MISSION" else # 預設:只讀,不消耗——待核請求留著,等真正要放行的那一步帶 --consume 才用掉 echo "ARMED(唯讀): $MISSION" echo " ⚠️ 唯讀模式,**核准還在**(沒有被用掉)。真正要放行的那一步會自己帶 --consume。" >&2 fi RESULT=0 [ -n "$WANT_NONCE" ] && break else echo "…$NONCE(#$ISSUE)還沒等到 Leo 的回覆" >&2 fi done < "$ELIGIBLE_TSV" exit $RESULT