4322deb23a
總管自己驗這一格量到 exit=0,我量到 exit=1。**兩個都是真的**,
而分歧本身就是最後一塊拼圖:
**GNU 的 `-f` 是 `--file-system`,布林旗標、不接格式字串**
⇒ `%m` 不是格式,它被當成**另一個檔名運算元**
⇒ 離開碼取決於「cwd 裡有沒有一個叫 `%m` 的檔」:
A. 沒有(一般情況) → exit 1 ⇒ `||` 會跑 ⇒ 正確的秒數接在垃圾後面
B. 剛好有 → exit 0 ⇒ `||` 不會跑 ⇒ 整包連一個數字都沒有
兩種情況閘的結果一樣:MT 都不是純數字、戳記都作廢。(實測 GNU coreutils 9.4,兩種都重現過)
🔴 教訓比原本寫的更尖銳:舊寫法的 `||` fallback 救不了,
**不是因為它沒跑,而是因為「跑不跑」根本不由這支腳本決定**——
它由「cwd 裡有沒有某個檔名」決定。
一個行為取決於 cwd 有沒有某個檔的判斷式,不管跑不跑都是壞的。
⇒ 所以修法不能只是「把順序反過來」,**每一步都要驗它是不是純數字**
(lib/mtime.sh 本來就是這樣寫的,現在把理由寫進去了)。
gate-ok 測試補第 ⑰ 條守這一層:cwd 裡有一個叫 `%m` 的檔時,file_mtime 仍要回純數字。
16 → 17 條,TESTING.md 的 A16 一併更新。
196 lines
9.3 KiB
Markdown
196 lines
9.3 KiB
Markdown
# 雲端 session 的閘為什麼跟地端不一樣(inkstone/ISEP#90)
|
||
|
||
> leo 的目標一句話:**讓雲端 session 跟地端拿到同一組閘、同一組能用的工具。**
|
||
|
||
雲端不是「地端少幾支閘」,是**同一批閘在雲端的行為不一樣**。四個實例,
|
||
每一個都穩定重現,而且每一個都**不會自己喊痛**——這才是它們活這麼久的原因。
|
||
|
||
---
|
||
|
||
## 四個缺陷,兩種病
|
||
|
||
| | 缺陷 | 病 |
|
||
|---|---|---|
|
||
| ① | 未推警察每個雲端 session 都誤攔 | 判準在雲端**不成立** |
|
||
| ② | `scripts/ticket` 在雲端拿不到 token | 跑到的是**另一份**(舊複本) |
|
||
| ③ | 已刪除的機制在雲端重生 | 載到的是**另一版**(0.3.9 vs 0.9.0) |
|
||
| ④ | 總管的「我確認過了」出口打不開 | 判準在雲端**不成立**(平台差異) |
|
||
|
||
①④ 是同一種病:**閘的判準寫的是地端才成立的假設**。
|
||
②③ 是同一種病:**同一個東西有兩份,而雲端跑到的是舊的那份**。
|
||
|
||
---
|
||
|
||
## ① 未推警察:判準是「有沒有 upstream」,而雲端的分支天生沒有
|
||
|
||
雲端 session 開出來的工作分支沒有 upstream,**內容卻等於遠端 main**:
|
||
|
||
```
|
||
薄殼 HEAD = 081c547d90dc2d80692485af083ca1f71f2094f2
|
||
GitHub 遠端 main = 081c547d90dc2d80692485af083ca1f71f2094f2 ← 同一顆
|
||
```
|
||
|
||
⇒ 每個雲端 session、每次收工都被攔一次(2026-08-27 一個 session 五次全是誤報)。
|
||
|
||
**修法**:判準改成「**遠端有沒有這顆 commit**」(`git ls-remote`)。
|
||
本機的 remote-tracking ref 只答得準「有」——08-28 實測那份 `origin/main` 落後遠端 4 天
|
||
——所以答「沒有」的時候才打網路。三態:有/沒有/**問不到**;問不到一律不報。
|
||
|
||
**順手補的**:`$CLAUDE_PROJECT_DIR` 在雲端是**薄殼根**,真身在 `$TOP/InkStoneCo/`。
|
||
舊的掃描清單四個路徑在雲端一個都不存在 ⇒ **真身有東西沒推,這支閘一輩子不會知道。**
|
||
同一支閘在雲端既亂叫、又看不到該看的地方。
|
||
|
||
---
|
||
|
||
## ② `scripts/ticket`:跑到的是舊複本,不是 plugin 那一份
|
||
|
||
- ISEP 的 `scripts/ticket` **早在 2026-08-20 就修好了**(掃所有 remote + 環境變數 fallback)
|
||
- 但雲端 cwd 是真身,那裡有一份 `InkStoneCo/scripts/ticket` 的**舊複本**,
|
||
取 token 邏輯還停在「只認名叫 `gitea` 的 remote」,而 `bootstrap.sh` 把 Gitea 設成 `origin`
|
||
- ⇒ 雲端一律死在「拿不到 gitea token」
|
||
|
||
🔴 **後果比「一支腳本壞了」嚴重**:`scripts/ticket` 是開票/留言的**正門**,
|
||
它一壞,人就繞過去直接打 Gitea API——**而那正是 `ticket-api-bypass-guard.sh` 在防的事**。
|
||
**一道閘把人逼去走它自己禁止的那條路,那道閘就是在製造違規。**
|
||
|
||
**ISEP 這半的修法**:信標在 session 開頭就點名「專案裡有 ISEP 腳本的舊複本,
|
||
而且**內容不同**」。判準不是檔名一樣,是**檔名一樣而內容不同**——同步過的複本不吵。
|
||
**真身那半(把那份複本同步或刪掉)不在 ISEP,要在 `inkstone/InkStoneCo` 修。**
|
||
|
||
---
|
||
|
||
## ③ 已刪除的機制在雲端重生
|
||
|
||
```
|
||
ISEP main .claude-plugin/plugin.json → 0.9.0
|
||
雲端實際載入 → 0.3.9 ← 差 7 個 release
|
||
```
|
||
|
||
0.3.9 裡還活著兩支在 v0.9.0 已整支刪除的 hook ⇒ `.claude/pending-verification/`
|
||
被清掉之後又長回來。**一個看不見的版本落差,會讓已經刪掉的機制在別人的工作區裡復活。**
|
||
|
||
**傳輸那半是 `inkstone/ISEP#67`**(新版到不到得了手上),本票不重複那件。
|
||
**ISEP 這半能做的是讓它不再看不見**:信標匿名讀 ISEP main 的 `plugin.json`
|
||
(D20 判準下屬於「讀」,不需開閘),落後就講清楚差幾版、怎麼重拍快照;
|
||
同時掃 `.claude/` 底下**這一份 plugin 的 hooks/scripts 一個字都沒提到**的目錄,
|
||
點名它們是殘骸。判準是「plugin 現在還認不認得它」,**不是關鍵字黑名單**
|
||
(leo 2026-08-17 已證明那條路 8 次誤攔、0 次正確攔截)。
|
||
|
||
---
|
||
|
||
## ④ 「總管可以放行」的門,在雲端是焊死的
|
||
|
||
三支閘(`prod-write-guard`/`main-and-prod-push-guard`/`stage-before-prod-guard`)
|
||
都是「擋下來、但**總管看過就能放行**」。它們判斷戳記新不新都用同一行:
|
||
|
||
```sh
|
||
MT=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
|
||
```
|
||
|
||
macOS(BSD stat)上 `-f %m` 就是 mtime,對的。
|
||
**GNU coreutils 的 `-f` 是「顯示檔案系統資訊」**,而且它**一邊回非零、一邊往 stdout
|
||
吐一整段區塊**:
|
||
|
||
```
|
||
$ stat -f %m /tmp/.probe
|
||
stat: cannot read file system information for '%m': No such file or directory ← stderr
|
||
File: "/tmp/.probe" ← stdout
|
||
ID: 0 Namelen: 255 Type: ext2/ext3
|
||
```
|
||
|
||
⇒ `2>/dev/null` 吃掉錯誤訊息、`||` 把正確的秒數接在那堆垃圾**後面**
|
||
⇒ `case "$NOW$MT" in *[!0-9]*) return 1` 必然命中
|
||
⇒ **在 Linux(=每一個雲端 session)上,那三支閘的戳記永遠不會被接受。**
|
||
|
||
### 再往下一層:`-f` 根本不吃格式參數,所以連離開碼都不可靠
|
||
|
||
總管 2026-08-28 自己驗這一格時量到 `exit=0`,而我量到 `exit=1`。
|
||
**兩個都是真的**,而分歧本身就是這個 bug 最後一塊拼圖:
|
||
|
||
**GNU 的 `-f` 是 `--file-system`,它是布林旗標、不接格式字串**
|
||
⇒ `%m` 不是格式,它被當成**另一個檔名運算元** ⇒ 離開碼取決於
|
||
「當前目錄裡有沒有一個叫 `%m` 的檔」:
|
||
|
||
```
|
||
$ cd /tmp/statprobe && touch .mt
|
||
|
||
# A. 沒有名為 %m 的檔(一般情況)
|
||
$ stat -f %m .mt ; echo "exit=$?"
|
||
stat: cannot read file system information for '%m': No such file or directory
|
||
File: ".mt" … ← 檔案系統資訊照樣印到 stdout
|
||
exit=1 ← `||` **會**跑 ⇒ 正確的秒數接在那堆垃圾後面
|
||
|
||
# B. 剛好有一個叫 %m 的檔
|
||
$ touch '%m' && stat -f %m .mt ; echo "exit=$?"
|
||
exit=0 ← `||` **不會**跑 ⇒ 整包連一個數字都沒有
|
||
```
|
||
|
||
(實測環境:`stat (GNU coreutils) 9.4`)
|
||
|
||
**兩種情況下閘的結果一模一樣**——`MT` 都不是純數字,
|
||
`case "$NOW$MT" in *[!0-9]*) return 1` 都必然命中,戳記都作廢:
|
||
|
||
```
|
||
情況 A:非數字 ⇒ return 1 ⇒ 戳記作廢
|
||
情況 B:非數字 ⇒ return 1 ⇒ 戳記作廢
|
||
```
|
||
|
||
🔴 **這一層才是真正該記住的教訓**:舊寫法的 `||` fallback 之所以救不了,
|
||
**不是因為它沒跑,而是因為「跑不跑」根本不由這支腳本決定**
|
||
——它由「當前目錄裡有沒有一個叫 `%m` 的檔」決定。
|
||
一個**行為取決於 cwd 裡有沒有某個檔名**的判斷式,不管跑不跑都是壞的。
|
||
|
||
⇒ 所以 `lib/mtime.sh` 的修法不是「把順序反過來」而已,是
|
||
**每一步都驗它是不是純數字**:這個 bug 的成因正是「命令失敗了卻還是印了東西」,
|
||
**只看離開碼會再被騙一次**。
|
||
|
||
📌 這一格的實害(總管 2026-08-28 原話):「我今天為了發一則通知,
|
||
用了兩種方式蓋 `prod-write-ok` 都無效,一度以為是權限問題。」
|
||
⇒ **閘不會告訴你門是壞的**,所以人會往錯的方向查(權限、classifier、設定),
|
||
而根因在閘自己身上。
|
||
|
||
🔴 **後果**:那三支閘在雲端**等於純擋**。總管照著閘自己印的指示做,
|
||
做幾次都打不開,**而閘不會告訴他門是壞的**。
|
||
|
||
**為什麼活這麼久**:既有的三支測試(29/16/10 條)**只驗了「擋得住」,
|
||
一條都沒驗過「放得開」**。⇒ 這正是「閘的另外一半從來沒被測過」的代價。
|
||
|
||
**修法**:`hooks/lib/mtime.sh` —— 先 `-c %Y`(GNU)再 `-f %m`(BSD),
|
||
**每一步都驗它是不是純數字**(這個 bug 的成因正是「命令失敗了卻還是印了東西」,
|
||
只看離開碼會再被騙一次)。
|
||
|
||
### 附帶:逃生口收斂成一個入口 `scripts/gate-ok`
|
||
|
||
原本每支閘的門長得都不一樣,而且藏在被擋下的那則訊息裡:
|
||
|
||
```
|
||
touch /tmp/.prod-write-ok
|
||
git rev-parse --show-toplevel > /tmp/.main-push-ok
|
||
touch /tmp/.solo-ok-<session_id>
|
||
…
|
||
```
|
||
|
||
兩個後果:**記不住**(抄錯一個字門就打不開),以及**沒有穩定形狀可以事先放行**
|
||
——`.claude/settings.json` 的 allow 只能逐條完全比對(現場真的寫著
|
||
`Bash(touch /tmp/.prod-write-ok)` 這種一行),多一個 `&&`、換一個 session id
|
||
就落在規則之外,然後由權限層自己判斷。
|
||
|
||
⇒ `bash "$CLAUDE_PLUGIN_ROOT/scripts/gate-ok" <閘名> [參數]`:**一個名字、一種形狀**,
|
||
一條前綴規則涵蓋全部,以後新增閘不必再動一次設定。
|
||
|
||
🔴 **它沒有弱化任何一道閘**:蓋的是同一個檔、同一種語意——單次用完即丟、綁 repo、
|
||
綁 session、有效期全部沒動。換掉的只有「怎麼蓋」。
|
||
`hooks/tests/gate-ok.test.sh` 的 ⑥⑦⑧ 三條就是在守這件事(那三條性質是 08-11、08-12
|
||
兩次真的被穿透之後才補上的)。
|
||
|
||
---
|
||
|
||
## 還沒關掉的那兩格(不屬於 ISEP)
|
||
|
||
| 缺口 | 住在哪 |
|
||
|---|---|
|
||
| 雲端載到的版本追上 ISEP main | `inkstone/ISEP#67` |
|
||
| `InkStoneCo/scripts/ticket` 這份舊複本 | `inkstone/InkStoneCo`(真身) |
|
||
|
||
ISEP 這一側能做的是**讓它們不再是看不見的**:兩件現在都會在 session 開頭被信標點名。
|