c2638668e3
leo 2026-08-20:「同一個 plugin 你用,薄殼也用,保證兩邊同步」
「我要你幫雲端做薄殼,永遠都有問題,你要做的就是這組設定
你自己可以 dogfooding」
搬進來:41 支 hook(51 條註冊)/7 支 command/2 支 skill/23 支腳本。
不搬 .env、wiki、docs——那些是知識不是環境。
51 條 hook 路徑全部從 $CLAUDE_PROJECT_DIR/.claude/hooks/ 改成 ${CLAUDE_PLUGIN_ROOT}/hooks/,
零漏網。那正是薄殼一直壞掉的根:雲端 cwd 不是真身,寫死路徑就斷。
尚未驗證:Claude Code 能不能從私有 Gitea repo 裝 marketplace(要憑證)。
下一步就是在本機實際裝一次,通了才動雲端 bootstrap.sh。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
212 lines
11 KiB
Bash
Executable File
212 lines
11 KiB
Bash
Executable File
#!/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: <mission>"、把 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/<nonce>)" >&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
|