雲端要拿得到票、主線與通知:白名單住 ISEP、主線檔隨 repo 走、leo21c 讀放寫擋(inkstone/ISEP#130)

- docs/permissions-allow.json + scripts/settings-allow-sync:四個 Gitea 正門工具的權限白名單一份,
  setup script 裝完 plugin 寫一次、SessionStart 每次再對一次(只加不減、冪等)
- hooks/lib/mainline.py/scripts/mainline:家目錄沒主線就讀 InkStoneCo/system-dev/mainline.json;
  set/adopt/clear 兩份一起寫,refresh 只寫家目錄
- hooks/leo21c-write-guard.sh:唯讀 -d(tr/cut/sort…)先剪掉再判、notify_leo trigger 放行
  (與 prod-write-guard 同一份白名單)、改法段改印 09-02 起的 youlin 子網域;補第一支測試(26 條)
- prod-write-guard/main-and-prod-push-guard/kbdb-live-exam:認得 youlin 新子網域 arcrun-yuga3bse
- scripts/ticket:收件 repo 寫 inkstone/ISEP 不再 404(org 寫錯當場講)
- scripts/isep-notify:有 TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID 先走 Bot API 直送(雲端唯一通的路)
- 測試:A31–A35 共 79 條;README/plugin.json/hooks-inventory 數字實數(61 支、85 條、53 支腳本)

假設(記在這裡等 review):權限規則的形狀沿用 leo 09-07 親手加、實測有效的那四條;
「分類器真的不擋」要雲端一趟 run 的 permission_denials 才驗得到,本 PR 驗不了。
版本:待總管定版(plugin.json 仍 0.22.0)。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTZ9QtvjY7MNxjfQbexAm7
This commit is contained in:
isep-hand
2026-09-07 01:17:15 +00:00
parent 605f1fe5a6
commit 65120a1c65
27 changed files with 1141 additions and 34 deletions
+4
View File
@@ -281,6 +281,10 @@
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/mainline refresh"
},
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/settings-allow-sync --quiet"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/overdue-nag-guard.sh"
+31 -3
View File
@@ -20,6 +20,7 @@
# 判準(封動作,不封措辭 —— 同 empty-handed-stop-guard 的哲學):
# 命中 leo21c 的座標 + 這是一個寫入動作 ⇒ 擋
# 只是讀(GET/查詢/grep 到那個字串) ⇒ 放行
# 發一則 notify_leo(通知型 trigger,什麼都沒改)⇒ 放行(同 prod-write-guard 的白名單)
set -uo pipefail
payload=$(cat)
cmd=$(printf '%s' "$payload" | python3 -c "
@@ -60,8 +61,34 @@ hit=0
# ① 寫入請求打到那台 worker
if printf '%s' "$cmd" | grep -qiE 'https?://[^[:space:]"'"'"']*leo21c\.workers\.dev'; then
if printf '%s' "$cmd" | grep -qE -- '-X *(POST|PUT|PATCH|DELETE)|--data|--data-raw|-d[[:space:]]|/trigger'; then
hit=1
# 🔴 (a) 通知型 trigger 放行(inkstone/ISEP#130;判準與 prod-write-guard.sh 同一份):
# `…/webhooks/named/<ns>/notify_leo/trigger` 發一則 Telegram 給 leo**什麼都沒改**。
# wiki `agent-memory.md` 寫明它是找 leo 的正式通道;prod-write-guard 在 ISEP#63 就放行了它,
# 本閘卻因為路徑尾巴是 `/trigger` 照擋 ⇒ 兩支閘對同一條指令說不同的話,
# 而「發通知」被擋的結果是**雲端撞到人閘也叫不動 leo**inkstone/InkStoneCo#110)。
# 放行的範圍刻意只有一個名字:指令裡打到 leo21c 的**每一個**網址都得是它,
# 混進部署端點(`…/webhooks/named` 無名字)或別的工作流 ⇒ 照擋。
NOTIFY_ONLY=$(printf '%s' "$cmd" | python3 -c '
import re, sys
cmd = sys.stdin.read()
ALLOW = {"notify_leo"}
urls = [u for u in re.findall(r"https?://[^\s\"\x27<>]+", cmd) if "leo21c.workers.dev" in u.lower()]
PAT = re.compile(r"https?://[^/]+/webhooks/named/[^/]+/([A-Za-z0-9_-]+)/trigger/?$")
def notify(u):
m = PAT.match(u.rstrip(",;)"))
return bool(m) and m.group(1) in ALLOW
print("notify-only" if urls and all(notify(u) for u in urls) else "")
' 2>/dev/null || printf '')
if [ "$NOTIFY_ONLY" != "notify-only" ]; then
# 🔴 (b) 先剪掉唯讀工具的 `-d`inkstone/ISEP#13009-04 雲端實撞):
# `curl -s https://…leo21c…/x | tr -d '\r'`、`cut -d= -f2`、`sort -d`、`date -d` ……
# 這些 `-d` 沒有一個會寫到那台,舊判準 `-d[[:space:]]` 卻把整條純 GET 擋下
# ⇒ 「連讀都被擋」。prod-write-guard.sh 在 2026-08-12 就修過同一個洞,本閘漏了。
# 剪法與那支同一句 sed,剪完剩下的 `-d` 才是 curl 的 body。
cmd_w=$(printf '%s' "$cmd" | sed -E 's/(^|[|;&( ])(tr|cut|sort|uniq|date|xargs|paste|join|du|logger|split|comm)[[:space:]]+-d/\1\2 __READONLY_D__/g')
if printf '%s' "$cmd_w" | grep -qE -- '-X *(POST|PUT|PATCH|DELETE)|--request *(POST|PUT|PATCH|DELETE)|--data|--data-raw|-d[[:space:]]|/trigger'; then
hit=1
fi
fi
fi
@@ -87,7 +114,8 @@ leo 原話:「**leo21c 就是我這個普通用戶,不應該讓你去操控
改法:把目標明寫成 youlin,不要吃 `~/.arcrun/config.yaml` 的預設——
那個檔至今仍指著 leo21cKV id 與 encryption_key 是該實例專屬,換不過去)。
cypher : https://arcrun-cypher-executor.youlin-hsieh-dev.workers.dev
cypher : https://arcrun-cypher-executor.arcrun-yuga3bse.workers.dev
(09-02 重裝後的子網域;舊名 youlin-hsieh-dev 的 DNS 已不存在,打它一律 000)
ns : yuga3bse
CF : 1129efd7df2e8899d537e9c8fbabb6cb
token : 頂層 .env 的 CLOUDFLARE_API_TOKEN_YOULIN_CC_USE
+91 -12
View File
@@ -58,14 +58,57 @@ def path() -> str:
return os.path.join(state_dir(), STATE_NAME)
def load():
"""讀主線。沒有/壞掉 ⇒ None(=現在沒有主線),**不是例外**。
# ── repo 裡的那一份(inkstone/ISEP#130)──────────────────────────────
#
# 主線原本只住在 `~/.claude/isep-countdown/mainline.json`——**這台機器的家目錄**。
# 雲端 session 是另一台機器:家目錄是空的,clone 下來的 repo 才是它唯一拿得到的東西。
# 2026-09-05 leo:「routine 每天早上開啟後抓不到任務」——不是 Gitea 沒票,
# 是雲端根本不知道主線是哪一條。
# ⇒ `inkstone/InkStoneCo` 的 `system-dev/mainline.json` 是**隨 repo 走的那一份**
# PR inkstone/InkStoneCo#118 放進去的)。這裡的規矩:
# · 讀:家目錄那份沒有 ⇒ 讀 repo 那份(雲端就是這個形狀)。
# 兩份都在 ⇒ 用 `set_at` 較新的(誰後標的誰算數,不分本機雲端)。
# · 寫:`set``adopt``clear` 這種「主線是誰」的改動 ⇒ 兩份一起寫(`mirror=True`)。
# `refresh`/閘順手學到成員 ⇒ 只寫家目錄那份——否則每開一個 session
# InkStoneCo 的工作樹就髒一次,而髒的 diff 沒有人會去 commit。
# · repo 那份要 commit/開 PR 才到得了雲端;這支不碰 git。
REPO_REL = os.path.join("system-dev", STATE_NAME)
「沒有主線」是一個正常狀態,不是故障:ISEP#82 驗收第 4 條寫死了
「沒有任何主線時不能整組壞掉」。所以這支從不 raise。
"""
def repo_candidates():
"""repo 那一份可能在哪:明指 > 專案根 > 專案根底下的 InkStoneCo/(雲端薄殼的形狀)> cwd 同兩層。"""
out = []
forced = os.environ.get("ISEP_MAINLINE_REPO_FILE", "").strip()
if forced == "none":
return [] # 測試用:把「repo 那份」整個拿掉,外界定住
if forced:
out.append(forced)
roots = []
for r in (os.environ.get("CLAUDE_PROJECT_DIR", "").strip(), os.getcwd()):
if r:
roots += [r, os.path.join(r, "InkStoneCo")]
for r in roots:
p = os.path.join(r, REPO_REL)
if p not in out:
out.append(p)
return out
def repo_path(for_write=False):
"""讀:第一個**存在**的檔。寫:第一個 `system-dev/` 目錄存在的位置(檔可以還沒有)。
找不到 ⇒ None(雲端沒 clone InkStoneCo、或 cwd 在別的 repo 時都是正常狀態)。"""
for p in repo_candidates():
if for_write:
if os.path.isdir(os.path.dirname(p)):
return p
elif os.path.isfile(p):
return p
return None
def _read(p):
try:
with open(path()) as f:
with open(p) as f:
d = json.load(f)
except Exception:
return None
@@ -74,23 +117,59 @@ def load():
return d
def save(d) -> bool:
def load():
"""讀主線。沒有/壞掉 ⇒ None(=現在沒有主線),**不是例外**。
「沒有主線」是一個正常狀態,不是故障:ISEP#82 驗收第 4 條寫死了
「沒有任何主線時不能整組壞掉」。所以這支從不 raise。
家目錄那份與 repo 那份都在時,`set_at` 較新的算數(格式固定 `YYYY-MM-DD HH:MM`
字串比大小就是時間比大小);一樣新 ⇒ 家目錄那份(它才有 refresh 過的進度)。
"""
local = _read(path())
rp = repo_path()
remote = _read(rp) if rp else None
if local and remote:
return remote if (remote.get("set_at") or "") > (local.get("set_at") or "") else local
return local or remote
def save(d, mirror=False) -> bool:
"""寫家目錄那份;`mirror=True` 時同時寫 repo 那份(只有「主線是誰」變了才這樣叫)。
回 True/False 只看家目錄那份——repo 那份寫不寫得進去用 `repo_path(for_write=True)` 另外問。"""
try:
with open(path(), "w") as f:
json.dump(d, f, ensure_ascii=False, indent=2)
return True
except Exception:
return False
if mirror:
rp = repo_path(for_write=True)
if rp:
try:
with open(rp, "w") as f:
json.dump(d, f, ensure_ascii=False, indent=2)
f.write("\n")
except Exception:
pass
return True
def clear() -> bool:
def clear(mirror=False) -> bool:
ok = True
try:
os.remove(path())
return True
except FileNotFoundError:
return True
pass
except Exception:
return False
ok = False
if mirror:
rp = repo_path()
if rp:
try:
os.remove(rp)
except Exception:
ok = False
return ok
def parse_ts(raw):
+2 -1
View File
@@ -315,12 +315,13 @@ fi
#
# 整套測試環境=下列任一出現在命令裡:
# · `youlin-hsieh-dev` ── 帳號名(D37 定的 stage 帳號)
# · `arcrun-yuga3bse` ── 該帳號 09-02 重裝後的 workers.dev 子網域(inkstone/ISEP#130;舊名 DNS 已死)
# · `1129efd7df2e8899d537e9c8fbabb6cb` ── 該帳號 ID(用 env 指過去時只有它看得見)
# · `_YOULIN_` ── 憑證變數名(如 `CLOUDFLARE_API_TOKEN_YOULIN_CC_USE`
# · `X-Arcrun-API-Key: youlin` ── 打該實例 API 時的身分標頭
case "$CMD" in
*staging*|*-staging*|*"--env stage"*|*"--env=stage"*|*stage.*) exit 0 ;;
*youlin-hsieh-dev*|*1129efd7df2e8899d537e9c8fbabb6cb*|*_YOULIN_*|*"X-Arcrun-API-Key: youlin"*) exit 0 ;;
*youlin-hsieh-dev*|*arcrun-yuga3bse*|*1129efd7df2e8899d537e9c8fbabb6cb*|*_YOULIN_*|*"X-Arcrun-API-Key: youlin"*) exit 0 ;;
esac
# 🔴 2026-08-12 leo 拍板「加閘」:**geek6688(出貨機)是明文授權「總管可以直接動」的那一台。**
+3
View File
@@ -227,6 +227,9 @@ fi
case "$CMD" in
*staging*|*-staging*|*"--env stage"*|*"--env=stage"*|*stage.*|*"-staging"*) exit 0 ;;
*youlin-hsieh-dev*|*"X-Arcrun-API-Key: youlin"*) exit 0 ;;
# 🔴 2026-09-02 youlin 重裝後 workers.dev 子網域換成 `arcrun-yuga3bse`inkstone/ISEP#130):
# 舊名 DNS 已不存在。認不得新名 ⇒ 打 stage 做實驗又會被當成 prod 擋——同一個誤攔第二次。
*arcrun-yuga3bse*) exit 0 ;;
esac
+1
View File
@@ -15,6 +15,7 @@ HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/countdown-guard.sh}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
export ISEP_COUNTDOWN_STATE_DIR="$TMP/state"
export ISEP_MAINLINE_REPO_FILE=none # 把 repo 那份鏡像拿掉(inkstone/ISEP#130):這支測的是家目錄那份
mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
PASS=0; FAIL=0
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# leo21c-write-guard.sh 的迴歸測試(inkstone/ISEP#130
#
# ── 這支在守什麼 ──────────────────────────────────────────────────────
# 閘的判準是「**讀可以,寫不行**」(leo 2026-08-20leo21c 是他本人在用的知識庫)。
# 2026-09-04 雲端 Routine 的 run log**讀 notify_leo 的定義被擋** ——
# 讀都讀不到,雲端就什麼都拿不到(inkstone/ISEP#130 開票的第一件)。
#
# 這支之前**沒有任何測試**,所以「讀會不會被誤攔」從來沒被驗過。
# 下面的測資是 Routine 文件(cloud-worker.mdprogress-guard.md)裡**真的會下的那幾條指令**,
# 不是想像出來的例子。
#
# 🔴 三個方向都要驗,缺一個就是假綠:
# A 讀 ⇒ 放行(含 `tr -d``cut -d=` 這種唯讀的 -d,那正是 09-04 被擋的形狀)
# B 寫 ⇒ 擋(心跳 POST/PATCH 收件匣/部署端點/別的 trigger)
# C 閘印出來的「改法」指的是**現役**的 youlin 網址,不是 09-02 已死的舊名
#
# 這支閘不寫任何檔案,直接跑真跡即可;全程離線。
# 用法:bash hooks/tests/leo21c-write-guard.test.sh
set -u
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
HOOK="$HERE/../leo21c-write-guard.sh"
PASS=0; FAIL=0
mk(){ python3 -c "import json,sys;print(json.dumps({'tool_name':'Bash','tool_input':{'command':sys.argv[1]}}))" "$1"; }
t(){ # t <期望 pass|block> <說明> <指令>
LAST_ERR=$(mk "$3" | bash "$HOOK" 2>&1 >/dev/null); rc=$?
got=$([ "$rc" -eq 2 ] && echo block || echo pass)
if [ "$got" = "$1" ]; then echo "$2"; PASS=$((PASS+1))
else echo "$2 —— 期望 $1,實得 $gotrc=$rc"; FAIL=$((FAIL+1)); fi
}
L='https://arcrun-cypher-executor.leo21c.workers.dev'
K='-H "X-Arcrun-API-Key: leo"'
D='-d'
echo "── A. 讀 ⇒ 放行(雲端 Routine 真的會下的形狀)──"
t pass "① 讀收件匣(progress-guard.md 步驟 0 原句)" "curl -s \"$L/kbdb/entries?entry_type=inbox&limit=20\" $K"
t pass "② 讀 notify_leo 定義(09-04 被擋的那一條)" "curl -s $L/webhooks/named/leo/notify_leo $K"
t pass "③ 讀 tr -d(唯讀的 -d,不是 body" "curl -s $L/workflows/notify_leo $K | tr $D '\\r'"
t pass "④ 讀 cut -d=" "NS=\$(grep ns cfg | cut ${D}= -f2); curl -s $L/kbdb/entries?limit=1 $K"
t pass "⑤ 讀 sort -d date -d" "curl -s $L/health | sort $D; date $D yesterday"
t pass "⑥ 明寫 -X GET" "curl -s -X GET $L/kbdb/entries?entry_type=dash_beat $K"
t pass "⑦ 讀心跳(progress-guard 2.6 原句)" "curl -s \"$L/kbdb/entries?entry_type=dash_beat&limit=30\" $K"
t pass "⑧ git commit 訊息提到 leo21c 與 POSTgit 寫不到那台)" "git commit -m 'fix: curl -X POST $L/kbdb/entries 會被擋'"
t pass "⑨ 修這道閘自己" "sed -i 's/a/b/' hooks/leo21c-write-guard.sh"
t pass "⑩ 打 youlin09-02 起的新子網域)POST,不是 leo21c" "curl -X POST https://arcrun-cypher-executor.arcrun-yuga3bse.workers.dev/webhooks/named $D '{}'"
t pass "⑪ 用 youlin 的 CF 帳號部署" "CLOUDFLARE_ACCOUNT_ID=1129efd7df2e8899d537e9c8fbabb6cb npx wrangler deploy"
t pass "⑫ 發一則 notify_leo(通知型 trigger,什麼都沒改;同 prod-write-guard 白名單)" "curl -s -X POST $L/webhooks/named/leo/notify_leo/trigger -H 'Content-Type: application/json' $D '{\"text\":\"[總管] hi\"}'"
echo "── B. 寫 ⇒ 擋 ──"
t block "⑬ 打心跳 POSTcloud-worker.md 步驟 0 原句——它寫進 leo 的庫)" "curl -s -X POST \"$L/kbdb/entries\" $K -H 'Content-Type: application/json' $D '{\"entry_type\":\"dash_beat\"}'"
t block "⑭ PATCH 收件匣" "curl -s -X PATCH \"$L/kbdb/entries/123\" $K $D '{\"content\":\"x\"}'"
t block "⑮ DELETE" "curl -X DELETE $L/kbdb/entries/1 $K"
t block "⑯ 只帶 -d 的隱式 POST" "curl $L/kbdb/entries $D '{}'"
t block "⑰ --data-urlencode" "curl $L/kbdb/entries --data-urlencode text=hi"
t block "⑱ --request PUT" "curl --request PUT $L/kbdb/entries/1 $D '{}'"
t block "⑲ 別的工作流 triggership_refresh_cdn 會動線上狀態)" "curl -X POST $L/webhooks/named/leo/ship_refresh_cdn/trigger $D '{}'"
t block "⑳ 部署端點(無名字的 named webhook" "curl -X POST $L/webhooks/named $D @def.yaml"
t block "㉑ notify_leo 旁邊夾一個部署端點" "curl -X POST $L/webhooks/named/leo/notify_leo/trigger $D '{}' && curl -X POST $L/webhooks/named $D @x"
t block "㉒ 寫 + tr -d(剪掉唯讀 -d 之後仍該擋)" "cat f | tr $D '\\r' | curl -X POST $L/kbdb/entries $D @-"
t block "㉓ 用 leo21c 的 CF 帳號部署" "CLOUDFLARE_ACCOUNT_ID=51a01bfa2665bd7bc3fd080dc40cf3e1 npx wrangler deploy"
echo "── C. 擋下時印的改法要指到活著的 youlin ──"
mk "curl -X POST $L/kbdb/entries $D '{}'" | bash "$HOOK" 2>"$HERE/.leo21c-msg.$$" >/dev/null
MSG=$(cat "$HERE/.leo21c-msg.$$"); rm -f "$HERE/.leo21c-msg.$$"
if printf '%s' "$MSG" | grep -q 'arcrun-cypher-executor.arcrun-yuga3bse.workers.dev'; then
echo " ✅ ㉔ 改法段印的是現役子網域 arcrun-yuga3bse"; PASS=$((PASS+1))
else echo " ❌ ㉔ 改法段沒有現役子網域 arcrun-yuga3bse"; FAIL=$((FAIL+1)); fi
if printf '%s' "$MSG" | grep -q 'cypher : https://arcrun-cypher-executor.youlin-hsieh-dev'; then
echo " ❌ ㉕ 改法段還教人打 09-02 已死的 youlin-hsieh-dev"; FAIL=$((FAIL+1))
else echo " ✅ ㉕ 改法段不再把死的舊名當網址教人"; PASS=$((PASS+1)); fi
if printf '%s' "$MSG" | grep -q '讀可以,寫不行'; then
echo " ✅ ㉖ 訊息講得出判準(讀可以,寫不行)"; PASS=$((PASS+1))
else echo " ❌ ㉖ 訊息沒講判準"; FAIL=$((FAIL+1)); fi
echo
echo "通過 $PASS 條,失敗 $FAIL"
[ "$FAIL" -eq 0 ]
+1
View File
@@ -21,6 +21,7 @@ MAINLINE="$ROOT/../scripts/mainline"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
export ISEP_COUNTDOWN_STATE_DIR="$TMP/state"
export ISEP_MAINLINE_REPO_FILE=none # 把 repo 那份鏡像拿掉(inkstone/ISEP#130):這支測的是家目錄那份
export TICKET_HOST="http://127.0.0.1:9" # 連不上:走到網路就會馬上失敗
mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
unset ISEP_MAINLINE_FIXTURE
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# 主線那個檔「隨 repo 走」的迴歸測試(inkstone/ISEP#130
#
# ── 這支在守什麼 ──────────────────────────────────────────────────────
# 主線原本只住在家目錄(`~/.claude/isep-countdown/mainline.json`)。雲端是另一台機器,
# 家目錄空的、clone 下來的 repo 才是它拿得到的東西 ⇒ 2026-09-05 leo
# 「routine 每天早上開啟後抓不到任務」。PR inkstone/InkStoneCo#118 把主線檔放進
# `InkStoneCo/system-dev/mainline.json`,這裡驗 ISEP 這一半:
# A 讀:家目錄沒有 ⇒ 讀 repo 那份(雲端的形狀:$CLAUDE_PROJECT_DIR 是薄殼根,真身在 InkStoneCo/
# B 兩份都在 ⇒ `set_at` 較新的算數,不分本機雲端
# C 寫:`set``adopt``clear` 兩份一起動;`refresh` 那種只寫家目錄(不弄髒 InkStoneCo 工作樹)
# D 哪裡都沒有 ⇒ 仍然答得出「現在沒有主線」(ISEP#82 驗收第 4 條沒有被本票弄壞)
#
# 用法:bash hooks/tests/mainline-repo-mirror.test.sh
# 🔴 全程離線:狀態走 ISEP_COUNTDOWN_STATE_DIR、repo 那份走 TMP 底下的假 InkStoneCo、
# TICKET_HOST 指到連不上的位址。不碰真家目錄、不碰真 InkStoneCo。
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MAINLINE="$ROOT/../scripts/mainline"
LIB="$ROOT/lib/mainline.py"
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
export ISEP_COUNTDOWN_STATE_DIR="$TMP/home-state"; mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
export TICKET_HOST="http://127.0.0.1:9"
unset ISEP_MAINLINE_REPO_FILE ISEP_MAINLINE_FIXTURE
SHELL_ROOT="$TMP/shell"; HUB="$SHELL_ROOT/InkStoneCo"; mkdir -p "$HUB/system-dev"
PASS=0; FAIL=0
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
write_ms(){ # write_ms <檔> <標題> <set_at>
python3 - "$1" "$2" "$3" <<'PY'
import json, sys
json.dump({"ref": "inkstone/Arcrun#48", "owner": "inkstone", "repo": "Arcrun", "id": 48,
"title": sys.argv[2], "description": "測試用。", "due_on": "2026-09-13T23:59:59Z",
"open_issues": 3, "closed_issues": 0, "members": ["inkstone/ISEP#130"],
"set_at": sys.argv[3], "refreshed_at": sys.argv[3]},
open(sys.argv[1], "w"), ensure_ascii=False, indent=2)
PY
}
show(){ CLAUDE_PROJECT_DIR="$1" python3 "$MAINLINE" 2>&1 | head -1; }
echo "── A. 家目錄沒有 ⇒ 讀 repo 那份 ──"
write_ms "$HUB/system-dev/mainline.json" "雲端才讀得到的那一條" "2026-09-07 07:30"
OUT=$(show "$SHELL_ROOT")
case "$OUT" in *"雲端才讀得到的那一條"*) ok "① \$CLAUDE_PROJECT_DIR 是薄殼根、真身在 InkStoneCo/(雲端的形狀)⇒ 答得出主線" ;;
*) no "① 薄殼根形狀讀不到 repo 那份" "$OUT" ;; esac
OUT=$(show "$HUB")
case "$OUT" in *"雲端才讀得到的那一條"*) ok "② \$CLAUDE_PROJECT_DIR 直接是 InkStoneCo ⇒ 也答得出" ;;
*) no "② InkStoneCo 根讀不到" "$OUT" ;; esac
OUT=$(cd "$HUB" && env -u CLAUDE_PROJECT_DIR python3 "$MAINLINE" 2>&1 | head -1)
case "$OUT" in *"雲端才讀得到的那一條"*) ok "③ 沒設 \$CLAUDE_PROJECT_DIR、只是 cwd 在 InkStoneCo ⇒ 也答得出" ;;
*) no "③ cwd 形狀讀不到" "$OUT" ;; esac
[ ! -e "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" ] && ok "④ 只是讀,家目錄那份沒有被順手建出來" \
|| no "④ 讀的時候寫了家目錄" "$(ls "$ISEP_COUNTDOWN_STATE_DIR")"
echo "── B. 兩份都在 ⇒ set_at 較新的算數 ──"
write_ms "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "本機舊的" "2026-09-01 10:00"
OUT=$(show "$SHELL_ROOT")
case "$OUT" in *"雲端才讀得到的那一條"*) ok "⑤ repo 那份較新(別台機器後標的)⇒ 用 repo 那份" ;;
*) no "⑤ 沒用較新的 repo 那份" "$OUT" ;; esac
write_ms "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "本機新的" "2026-09-08 09:00"
OUT=$(show "$SHELL_ROOT")
case "$OUT" in *"本機新的"*) ok "⑥ 家目錄那份較新 ⇒ 用家目錄那份" ;;
*) no "⑥ 沒用較新的家目錄那份" "$OUT" ;; esac
write_ms "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "同時標的(家目錄)" "2026-09-07 07:30"
OUT=$(show "$SHELL_ROOT")
case "$OUT" in *"同時標的(家目錄)"*) ok "⑦ 一樣新 ⇒ 家目錄那份(它才有 refresh 過的進度)" ;;
*) no "⑦ 平手沒選家目錄" "$OUT" ;; esac
echo "── C. 寫:誰該動 repo 那份 ──"
R=$(CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 - "$LIB" "$HUB/system-dev/mainline.json" <<'PY'
import importlib.util, json, os, sys
spec = importlib.util.spec_from_file_location("ml", sys.argv[1]); ML = importlib.util.module_from_spec(spec); spec.loader.exec_module(ML)
rp = sys.argv[2]
before = open(rp).read()
d = ML.load(); d["refreshed_at"] = "2026-09-09 00:00"; d["open_issues"] = 1
ML.save(d) # refresh 的形狀:只寫家目錄
r1 = open(rp).read() == before
d["title"] = "set 之後的新主線"; d["set_at"] = "2026-09-09 00:01"
ML.save(d, mirror=True) # setadopt 的形狀:兩份一起
r2 = json.load(open(rp)).get("title") == "set 之後的新主線"
r3 = json.load(open(ML.path())).get("title") == "set 之後的新主線"
r4 = open(rp).read().endswith("\n")
print("|".join(str(x) for x in (r1, r2, r3, r4)))
PY
)
[ "${R%%|*}" = "True" ] && ok "⑧ save()refresh 的形狀)不碰 repo 那份——不弄髒 InkStoneCo 工作樹" || no "⑧ refresh 動了 repo 那份" "$R"
R2=${R#*|}; [ "${R2%%|*}" = "True" ] && ok "⑨ save(mirror=True)setadopt 的形狀)寫進 repo 那份" || no "⑨ mirror 沒寫 repo" "$R"
R3=${R2#*|}; [ "${R3%%|*}" = "True" ] && ok "⑩ 同一次也寫了家目錄那份" || no "⑩ 家目錄沒寫" "$R"
[ "${R##*|}" = "True" ] && ok "⑪ repo 那份收尾有換行(diff 才不會每次多一行 no newline" || no "⑪ 沒換行" "$R"
OUT=$(CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 "$MAINLINE" clear 2>&1)
if [ ! -e "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" ] && [ ! -e "$HUB/system-dev/mainline.json" ]; then
ok "⑫ clear ⇒ 兩份都拿掉(否則下次讀又從 repo 那份長回來)"
else no "⑫ clear 沒清乾淨" "$(ls "$ISEP_COUNTDOWN_STATE_DIR" "$HUB/system-dev" 2>&1 | tr '\n' ' ')"; fi
case "$OUT" in *"repo 那份也刪了"*) ok "⑬ clear 講得出 repo 那份也動了(要 commit 才到雲端)" ;; *) no "⑬ clear 沒講 repo 那份" "$OUT" ;; esac
# set 要打網路,這裡只驗它印出來的那段「repo 那份寫了沒」是接在 repo_path 上的
R=$(CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 - "$LIB" "$HUB" <<'PY'
import importlib.util, os, sys
spec = importlib.util.spec_from_file_location("ml", sys.argv[1]); ML = importlib.util.module_from_spec(spec); spec.loader.exec_module(ML)
print(ML.repo_path(for_write=True) == os.path.join(sys.argv[2], "system-dev", "mainline.json"))
PY
)
[ "$R" = "True" ] && ok "⑭ 檔還不存在時,寫的目標仍指到 InkStoneCo/system-dev/set 建得出它)" || no "⑭ 寫的目標不對" "$R"
echo "── D. 哪裡都沒有 ⇒ 仍然答得出「現在沒有主線」 ──"
OUT=$(show "$SHELL_ROOT")
case "$OUT" in *"現在沒有主線"*) ok "⑮ 兩份都沒有 ⇒ 「現在沒有主線」(ISEP#82 第 4 條沒被弄壞)" ;; *) no "⑮ 沒主線時答不出來" "$OUT" ;; esac
OUT=$(ISEP_MAINLINE_REPO_FILE=none show "$SHELL_ROOT")
case "$OUT" in *"現在沒有主線"*) ok "⑯ ISEP_MAINLINE_REPO_FILE=none ⇒ 不看 repo(既有測試靠它把外界定住)" ;; *) no "⑯ none 沒生效" "$OUT" ;; esac
printf 'not json' > "$HUB/system-dev/mainline.json"
OUT=$(show "$SHELL_ROOT")
case "$OUT" in *"現在沒有主線"*) ok "⑰ repo 那份壞掉 ⇒ 當成沒有,不炸" ;; *) no "⑰ 壞檔炸了" "$OUT" ;; esac
echo; echo "通過 $PASS 條,失敗 $FAIL"
[ "$FAIL" -eq 0 ]
+1
View File
@@ -23,6 +23,7 @@ t pass "GET + sort -d / xargs -d" "ls | sort $DASH_D | xargs $DASH_D '\\n' echo;
t pass "GET 帶 Bearer 標頭" "curl -s -H 'Authorization: Bearer xxx' $H/templates?limit=1"
t pass "打 staging" "curl -X POST https://arcrun-rag-installer-staging.workers.dev/x $DASH_D '{}'"
t pass "打 youlin 測試場" "curl -X POST https://arcrun-cypher-executor.youlin-hsieh-dev.workers.dev/x $DASH_D '{}'"
t pass "打 youlin 測試場(09-02 重裝後的子網域 arcrun-yuga3bseinkstone/ISEP#130" "curl -X POST https://arcrun-cypher-executor.arcrun-yuga3bse.workers.dev/webhooks/named $DASH_D @def.yaml"
t pass "打 Gitea(不是實例)" "curl -X POST https://git.uncle6.me/api/v1/repos/Leo/x/issues $DASH_D '{}'"
echo "── 該擋(寫入)──"