From 5d938f5d3a03082aa2b56332d58b0f9710a78ac5 Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 17 Sep 2026 19:58:50 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A5=A8=E4=B8=8A=E5=9B=9E=E7=A2=BC=E8=A7=A3?= =?UTF-8?q?=E7=9A=84=E4=BF=9D=E9=9A=AA=E5=AF=AB=E5=9B=9E=E5=B0=88=E6=A1=88?= =?UTF-8?q?=E6=A0=B9=EF=BC=8C=E5=87=BA=E8=B2=A8=E7=B7=9A=E8=AE=80=E5=BE=97?= =?UTF-8?q?=E5=88=B0=EF=BC=88inkstone/ISEP#156=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gitea-arm-to-github-armed.sh 原本用腳本自己的上一層決定 .github-armed 寫在哪; plugin 發佈後那是 ~/.claude/plugins/cache/inkstone/isep//, 於是保險寫進快取、印「已解」、ship.mjs 與各閘讀不到(arcrun-rag#202 c7598)。 改寫到 gitea_arm_proj_dir(${CLAUDE_PROJECT_DIR:-$(pwd)}),與 main-and-prod-push-guard/github-contact-guard/stage-before-prod-guard 讀的位置、 以及 check 自己讀 token/待核請求的位置一致。擋下時印的 request 路徑改絕對路徑。 測試:hooks/tests/gitea-arm-to-github-armed.test.sh 18/18;修之前 11 條紅。 TESTING.md 補 A42。版本待總管定。 Co-Authored-By: Claude Opus 5 --- docs/TESTING.md | 28 ++++ hooks/tests/gitea-arm-to-github-armed.test.sh | 127 ++++++++++++++++++ scripts/gitea-arm-to-github-armed.sh | 28 +++- 3 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 hooks/tests/gitea-arm-to-github-armed.test.sh diff --git a/docs/TESTING.md b/docs/TESTING.md index 0c69642..0f3f993 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1252,6 +1252,33 @@ bash scripts/test-isep-doctor-live.sh # 實機:真的 CLI 與 Desktop 內 - ⑨ 紅 ⇒ Desktop 版本用字串排序,`2.1.99` 會被當成比 `2.1.266` 新 - 實機測試印 SKIP ⇒ 這台的版本不是 2.1.220/2.1.266,行為可能不同,那一格**沒驗過** +### A42 — 票上回碼解的保險,寫在出貨線讀得到的地方:18 條 +``` +bash hooks/tests/gitea-arm-to-github-armed.test.sh . +``` +**該看到**:`通過 18 條,失敗 0 條`。**全程離線**:在 `mktemp` 裡造一個長得像 +`~/.claude/plugins/cache/inkstone/isep//` 的安裝位置與一個假的專案根, +「leo 已核准」由 stub 扮演;fail-closed 那組用**真的** `gitea-arm-check.sh`(沒有待核請求 → 本地就擋)。 +**不碰真 InkStoneCo、不寫真的 `.github-armed`、不打 Gitea。** +(②a 會去讀 `InkStoneCo/products/arcrun-rag/installer/scripts/d20-guard.mjs` 的 `checkArmed`; +找不到那個檔或沒有 node 時印 `⏭️ SKIP`,不算失敗,也不算驗過。) + +**它在守什麼**(`inkstone/ISEP#156`,09-17 實撞於 `inkstone/arcrun-rag#202` c7598): +舊版用 `ROOT=$(dirname $0)/..` 決定保險寫在哪。plugin 發佈後腳本住在 plugin 快取裡 +⇒ 保險寫進快取、腳本照印「✅ 保險已解」、出貨線讀 `InkStoneCo/.github-armed` 讀不到 +⇒ leo 回了碼,出貨照樣卡住,總管手動搬檔才通。 +現在寫在 `${CLAUDE_PROJECT_DIR:-$(pwd)}`——**跟每一個讀它的人同一個位置**。 + +📌 拿修之前的腳本跑是 **11 條紅**(2026-09-17 實跑),不是換了寫法就會綠的測試。 + +**失敗**: +- ① 的兩條 ★ 紅 ⇒ 保險又寫回腳本自己的位置(09-17 那個病) +- ② 任一 ★ 紅 ⇒ 檔案寫對地方了,但**讀的人不認得**(格式或位置跟出貨線/閘又分家了); + 它們的「對照」紅 ⇒ ★ 那條沒有鑑別力 +- ③ 紅 ⇒ 終端機(沒有 `CLAUDE_PROJECT_DIR`)時跟閘的退回規則不一致 +- ④ 前兩條紅 ⇒ **沒拿到核准也寫了保險**——這是 D20 等級的洞,最嚴重 +- ④ ★ 紅 ⇒ 擋下之後教的指令又印回相對路徑(站在 InkStoneCo 會叫到舊複本,`inkstone/ISEP#112` 同病) + --- ## B. 只有 leo 能跑的(雲端) @@ -1381,6 +1408,7 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面 | **A39 D20 閘看 push 實際會推的 repo** | isep-hand | ✅ 26/26(2026-09-07,inkstone/ISEP#109→6629)+真路徑實跑(薄殼 cwd:`-C InkStoneCo` 放行、裸 `git push origin` 擋)+舊閘實跑 7 條紅(4 誤攔+3 漏擋) | | **A24 分身住 repo 裡面+收工被收(G/H 群)** | isep-hand | ✅ 110/110(2026-09-07,inkstone/ISEP#147)+舊閘實跑 20 條紅(訊息+G/H,B/D/E/F 全綠)+本線自己就是 dogfood(分身開在 `ISEP/.worktrees/ISEP-147`,主目錄 `git status` 乾淨) | | **A40 sweep 現場整理(七個分身的 fixture)** | isep-hand | ✅ 42/42(2026-09-07,inkstone/ISEP#147);**leo 的 Mac 現場**要他自己跑 `sweep ~/tech_projects`(先 dry-run)——這台機器碰不到 | +| **A42 票上回碼的保險寫在出貨線讀得到的地方** | isep-hand | ✅ 18/18(2026-09-17,inkstone/ISEP#156);修之前的腳本 11 條紅 | | **A26 雲端憑證清單這台拿得到** | isep-hand | ✅ 11/11(2026-09-01,inkstone/ISEP#115→5526/5541/5542/5577)+兩條「該紅」方向實跑 | | **A16 放行的門真的打得開** | 總管 | ✅ 17/17(2026-08-28,inkstone/ISEP#90) | | **A17 未推警察不誤攔雲端分支** | 總管 | ✅ 10/10(2026-08-28,inkstone/ISEP#90) | diff --git a/hooks/tests/gitea-arm-to-github-armed.test.sh b/hooks/tests/gitea-arm-to-github-armed.test.sh new file mode 100644 index 0000000..5877db2 --- /dev/null +++ b/hooks/tests/gitea-arm-to-github-armed.test.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# gitea-arm-to-github-armed.sh 的迴歸測試——**保險寫在哪,讀的人就要在哪讀得到**。 +# +# 🔴 它在守什麼(inkstone/ISEP#156,2026-09-17 實撞,inkstone/arcrun-rag#202): +# ISEP 以 plugin 形式裝在 `~/.claude/plugins/cache/inkstone/isep//`, +# 舊版用 `ROOT=$(dirname $0)/..` 決定 `.github-armed` 寫在哪 +# ⇒ 保險被寫進 **plugin 快取資料夾**;而出貨線(ship.mjs → d20-guard.mjs) +# 與每一支讀它的閘(main-and-prod-push-guard/github-contact-guard/stage-before-prod-guard) +# 讀的都是**專案根**(`${CLAUDE_PROJECT_DIR:-$(pwd)}`) +# ⇒ leo 在票上回了碼、腳本印「✅ 保險已解」,出貨照樣卡住。 +# **比單純失敗更糟:它對 leo 說謊。** +# +# 判準是「要求某個東西在場」:保險檔必須出現在讀的人會讀的那個位置, +# 並且**真的被那些讀的人認得**(不是只驗檔案存在)。 +# +# 全程離線:`gitea-arm-check.sh` 的核准本身(Leo 真的回覆)必須打真的 Gitea, +# 那部分由 gitea-arm-check.test.sh 與 inkstone/InkStoneCo#34 的人工實測負責。 +# 這裡把「核准成功」換成 stub,**只驗核准之後保險落在哪、被不被認得**; +# fail-closed 那條則用**真的** check(無待核請求 → 本地就擋,不碰網路)。 +# +# 用法:bash hooks/tests/gitea-arm-to-github-armed.test.sh . +set -u +REPO="$(cd "${1:?用法: $0 }" && pwd)" +SRC="$REPO/scripts/gitea-arm-to-github-armed.sh" +[ -f "$SRC" ] || { echo "❌ 找不到 $SRC(參數要傳 ISEP repo 根目錄)"; exit 1; } + +T=$(mktemp -d) +trap '[ -n "${KEEP:-}" ] && echo "保留 $T" || rm -rf "$T"' EXIT + +# ── 造一個長得像 plugin 快取的安裝位置,與一個長得像 InkStoneCo 的專案根 ── +PLUG="$T/.claude/plugins/cache/inkstone/isep/9.9.9" +PROJ="$T/InkStoneCo" +mkdir -p "$PLUG/scripts/lib" "$PROJ" "$T/elsewhere" +cp "$SRC" "$PLUG/scripts/" +cp "$REPO/scripts/lib/gitea-arm-common.sh" "$PLUG/scripts/lib/" +cp "$REPO/scripts/gitea-arm-request.sh" "$PLUG/scripts/" 2>/dev/null +# stub:扮演「leo 已在票上核准」——輸出格式照 gitea-arm-check.sh 成功時的 `ARMED: ` +cat > "$PLUG/scripts/gitea-arm-check.sh" <<'STUB' +#!/bin/sh +[ "${1:-}" = "--consume" ] || { echo "stub: 應該帶 --consume" >&2; exit 1; } +echo "ARMED: 測試出貨 ISEP#156" +STUB +chmod +x "$PLUG/scripts/"*.sh + +PASS=0; FAIL=0 +ok(){ echo " ✅ $1"; PASS=$((PASS+1)); } +ng(){ echo " ❌ $1"; FAIL=$((FAIL+1)); } +chk(){ if eval "$2"; then ok "$1"; else ng "$1"; fi; } + +echo "── ① 從 plugin 路徑執行,CLAUDE_PROJECT_DIR 指向專案根(總管 session 的實際形狀)──" +( cd "$T/elsewhere" && CLAUDE_PROJECT_DIR="$PROJ" sh "$PLUG/scripts/gitea-arm-to-github-armed.sh" ) >"$T/out1" 2>&1 +rc=$? +chk "離開碼 0" '[ $rc -eq 0 ]' +chk "★ 專案根出現 .github-armed" '[ -f "$PROJ/.github-armed" ]' +chk "★ plugin 快取資料夾裡沒有 .github-armed(09-17 實撞的位置)" '[ ! -e "$PLUG/.github-armed" ]' +chk "cwd 也沒有被寫(不是靠碰巧站對位置)" '[ ! -e "$T/elsewhere/.github-armed" ]' +chk "印出的「提前上保險」路徑就是真正寫下的那份" 'grep -qF "rm $PROJ/.github-armed" "$T/out1"' +chk "第 1 行是未來的到期 epoch" '[ "$(sed -n 1p "$PROJ/.github-armed")" -gt "$(date +%s)" ]' +chk "第 2 行帶任務說明" '[ "$(sed -n 2p "$PROJ/.github-armed")" = "測試出貨 ISEP#156" ]' + +echo "── ② 讀的人真的認得這份保險(不是只驗檔案存在)──" +# ②a 出貨線:ship.mjs 的 prod --confirm preflight 呼叫的就是 d20-guard.mjs 的 checkArmed(INKSTONE_ROOT) +D20="" +for c in "${INKSTONE_ROOT:-}" "$REPO/../InkStoneCo" "$REPO/../../InkStoneCo" "$HOME/Documents/tech_projects/InkStoneCo"; do + [ -n "$c" ] && [ -f "$c/products/arcrun-rag/installer/scripts/d20-guard.mjs" ] \ + && { D20="$(cd "$c" && pwd)/products/arcrun-rag/installer/scripts/d20-guard.mjs"; break; } +done +if [ -n "$D20" ] && command -v node >/dev/null 2>&1; then + node --input-type=module -e " + const { checkArmed } = await import(process.argv[1]); + const r = checkArmed(process.argv[2]); + console.log('checkArmed OK mission=' + r.mission); + " "$D20" "$PROJ" >"$T/out2" 2>&1 + rc=$? + chk "★ ship.mjs 的 checkArmed(專案根) 認得($(head -1 "$T/out2"))" '[ $rc -eq 0 ]' + node --input-type=module -e " + const { checkArmed } = await import(process.argv[1]); + checkArmed(process.argv[2]); + " "$D20" "$PLUG" >/dev/null 2>&1 + rc=$? + chk "對照:checkArmed(plugin 快取) 讀不到 ⇒ 上一條有鑑別力" '[ $rc -ne 0 ]' +else + echo " ⏭️ SKIP 找不到 arcrun-rag 的 d20-guard.mjs 或 node(不算失敗,也不算驗過)" +fi + +# ②b 閘:同一個位置(`${CLAUDE_PROJECT_DIR:-$(pwd)}/.github-armed`) +mk(){ python3 -c "import json,sys;print(json.dumps({'tool_name':'Bash','tool_input':{'command':sys.argv[1]},'cwd':sys.argv[2]}))" "$1" "$T/elsewhere"; } +gate(){ # gate → 回 hook 離開碼 + mk "$2" | ( cd "$T/elsewhere" && env -u CLAUDE_CODE_CHILD_SESSION CLAUDE_PROJECT_DIR="$3" bash "$1" ) >/dev/null 2>&1 +} +# 閘會寫 github-contact-log/狀態目錄——一律寫進暫存的專案根,不碰真的 InkStoneCo +mkdir -p "$PROJ/system-dev/docs/3-specs/autonomy-dispatch" "$T/empty/system-dev/docs/3-specs/autonomy-dispatch" +WR="npx wrangler deploy" +GH="git push https://github.com/example/example.git main" +gate "$REPO/hooks/main-and-prod-push-guard.sh" "$WR" "$PROJ"; rc=$? +chk "★ main-and-prod-push-guard:保險在專案根 ⇒ wrangler deploy 放行" '[ $rc -ne 2 ]' +gate "$REPO/hooks/main-and-prod-push-guard.sh" "$WR" "$T/empty"; rc=$? +chk "對照:沒有保險 ⇒ 同一條被擋" '[ $rc -eq 2 ]' +gate "$REPO/hooks/github-contact-guard.sh" "$GH" "$PROJ"; rc=$? +chk "★ github-contact-guard:保險在專案根 ⇒ push github 放行" '[ $rc -ne 2 ]' +gate "$REPO/hooks/github-contact-guard.sh" "$GH" "$T/empty"; rc=$? +chk "對照:沒有保險 ⇒ 同一條被擋" '[ $rc -eq 2 ]' + +echo "── ③ 沒設 CLAUDE_PROJECT_DIR(leo 在終端機)⇒ 跟閘一樣退回 cwd ──" +rm -f "$PROJ/.github-armed" +( cd "$PROJ" && env -u CLAUDE_PROJECT_DIR sh "$PLUG/scripts/gitea-arm-to-github-armed.sh" ) >"$T/out3" 2>&1 +rc=$? +chk "離開碼 0" '[ $rc -eq 0 ]' +chk "寫在 cwd(專案根),不在 plugin 快取" '[ -f "$PROJ/.github-armed" ] && [ ! -e "$PLUG/.github-armed" ]' + +echo "── ④ fail-closed:沒拿到核准就一個檔都不寫(用真的 check,不碰網路)──" +rm -f "$PROJ/.github-armed" "$PLUG/.github-armed" +cp "$REPO/scripts/gitea-arm-check.sh" "$PLUG/scripts/gitea-arm-check.sh" +printf 'GITEA_TOKEN_CLAUDE_CODE=test-token-not-real\n' > "$PROJ/.env" +( cd "$T/elsewhere" && CLAUDE_PROJECT_DIR="$PROJ" sh "$PLUG/scripts/gitea-arm-to-github-armed.sh" ) >"$T/out4" 2>&1 +rc=$? +chk "沒有待核請求 ⇒ 離開碼非 0" '[ $rc -ne 0 ]' +chk "專案根與 plugin 快取都沒有 .github-armed" '[ ! -e "$PROJ/.github-armed" ] && [ ! -e "$PLUG/.github-armed" ]' +# 出路要走得通:訊息印的那支 request 腳本必須是真的存在的絕對路徑 +# (相對的 `scripts/…` 會對貼上那個人的 cwd 解析,站在 InkStoneCo 會叫到舊複本——ISEP#112 同一個病) +# 取「本檔自己印的」那一行(⛔ 之後);中文冒號不是空白,所以用「⛔ 之後第一個以 / 開頭的 token」 +REQ=$(sed -n '/⛔/,$p' "$T/out4" | grep -oE '[^[:space:]:]*gitea-arm-request\.sh' | grep '^/' | head -1) +chk "★ 出路印的是存在的絕對路徑(印出:${REQ:-無})" '[ -n "$REQ" ] && [ -f "$REQ" ]' + +echo +echo "通過 $PASS 條,失敗 $FAIL 條" +[ "$FAIL" -eq 0 ] diff --git a/scripts/gitea-arm-to-github-armed.sh b/scripts/gitea-arm-to-github-armed.sh index 6efe5ae..6643ce4 100755 --- a/scripts/gitea-arm-to-github-armed.sh +++ b/scripts/gitea-arm-to-github-armed.sh @@ -21,17 +21,36 @@ # ⇒ 拿掉那個條件,這支就變成「AI 自己批准自己發佈」——**那正是 D20 要擋的事**。 set -eu -ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -ARMED="$ROOT/.github-armed" +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +# shellcheck source=lib/gitea-arm-common.sh +. "$SCRIPT_DIR/lib/gitea-arm-common.sh" + +# ── 保險寫在哪:**讀的人在哪讀,就寫在哪**(inkstone/ISEP#156,2026-09-17 實撞)────── +# +# 🔴 舊版寫 `ROOT=$(dirname $0)/..`(腳本自己的上一層)。ISEP 還住在 InkStoneCo 裡時兩者剛好 +# 同一個位置;**改成 plugin 發佈後,腳本住在 `~/.claude/plugins/cache/inkstone/isep//`** +# ⇒ 保險被寫進 plugin 快取資料夾,印「✅ 保險已解」,而出貨線與每一道閘都讀不到 +# ⇒ leo 回了碼、出貨照樣卡住,總管手動把檔案搬過去才通(inkstone/arcrun-rag#202 c7598)。 +# **一句「已解」卻沒解,比單純失敗更糟——它讓 leo 以為自己的動作生效了。** +# +# ⇒ 改用 `gitea_arm_proj_dir`(=`${CLAUDE_PROJECT_DIR:-$(pwd)}`),理由是**跟讀的人對齊**: +# · main-and-prod-push-guard.sh/github-contact-guard.sh/stage-before-prod-guard.sh +# 全部讀 `${CLAUDE_PROJECT_DIR:-$(pwd)}/.github-armed` +# · ship.mjs 讀 `InkStoneCo/.github-armed`(總管 session 的 CLAUDE_PROJECT_DIR 就是它) +# · 本檔自己的核准來源(`gitea-arm-check.sh` 讀的 token 與待核請求)本來就住在這裡 +# ⇒ 專案根猜錯時 check 會先因為讀不到 `.env`/待核請求而 fail-closed,**不會寫到錯的地方** +# 不要再改回「腳本的位置」:腳本住哪是安裝方式決定的,保險住哪是讀的人決定的,兩者無關。 +ARMED="$(gitea_arm_proj_dir)/.github-armed" # ── 唯一的授權來源:leo 在 Gitea 票上的回覆 ──────────────────────────── # 不捕捉、不吞錯——check 失敗就讓它把原因印到 stderr 並中止本支。 # 🔴 `--consume` 是必要的(2026-08-16 起 check 預設唯讀):本檔會**寫出保險**, # 那就是「放行」本身 ⇒ 這一次核准必須當場作廢,否則同一組碼能重複解保險。 -OUT=$("$ROOT/scripts/gitea-arm-check.sh" --consume) || { +OUT=$("$SCRIPT_DIR/gitea-arm-check.sh" --consume) || { echo "" >&2 echo "⛔ 沒有取得 leo 的核准 ⇒ 不寫保險(fail-closed)" >&2 - echo " 先跑:scripts/gitea-arm-request.sh <票號> \"<要做什麼>\",把代碼給 leo 回在那張票上。" >&2 + # 印絕對路徑:相對的 `scripts/…` 會對貼上那個人的 cwd 解析,站在 InkStoneCo 會叫到舊複本(ISEP#112 同病) + echo " 先跑:$SCRIPT_DIR/gitea-arm-request.sh <票號> \"<要做什麼>\",把代碼給 leo 回在那張票上。" >&2 exit 1 } @@ -50,6 +69,7 @@ printf '%s\n%s\n%s\n' \ > "$ARMED" echo "✅ 保險已解(來源:leo 在 Gitea 票上的核准)" +echo " 寫在:$ARMED" echo " 任務:$MISSION" echo " 有效至:$(date -r "$EXPIRY" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "$EXPIRY")" echo " 提前上保險:rm $ARMED"