iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
AI Engineering

從單一agent 到多agent 集群的開發流水帳以及應用系列 第 5

Day 04(下):實錄全文 — 紅測、複檢裁決與被拆掉的結論

  • 分享至 

  • xImage
  •  

系列背景:我在用 Rust 打造私有自持的 AI agent 網格 spectyn-mesh
本篇是 Day 04 的實錄下集:上集(13 章圖文整理版 + 實錄精選)
見前一篇〈Day 04:一個不准猜的 CLI〉;這裡收錄其餘全部段落,
原文一字未改,按當天時序排列。母規則同前:
任何檢查必須先示範它會紅才算數。

Q3 · 我自己開的洞

Q3 原本的題目是「serve0.0.0.0/api 無憑證」。

我先去看程式碼,結果比我以為的好很多:auth_gate::require_cluster_auth_local_ui
是一個設計得相當仔細的閘門——它擋跨來源的網頁、用真實 socket 位址判斷 loopback
(不是可偽造的標頭)、遠端要 HMAC。既有測試裡甚至把陷阱寫在註解:

127.0.0.1 當測試位址會拿到 loopback 豁免,於是不管閘門在不在都會綠

寫得很好。問題是它逐 handler 呼叫

於是我早上加的那 16 個 handler——一個都沒呼叫它。

先讓它紅

18 of 18 command-table routes answered an unauthenticated remote caller:
  /api/life/note -> 200 OK
  /api/life/capture -> 200 OK
  ...

POST /api/life/note 回 200。同網段任何人都能往我的生活儲存區寫入事件。

我今天上午才寫了一整段,講「讓 app 搆得到生活軌道,才會從『我可以去終端機做』
變成『我會做』」。順手也讓隔壁的人搆得到了。

成因不是疏忽,是預設值的方向

逐 handler 的閘門,意思是新 handler 預設不驗證。要靠人記得。

而這張表的整個存在理由,就是「不准靠人記得」。

所以修法不是在 16 個 handler 各加一行,是把閘門做成涵蓋整份 routes() 的一層

let table = routes()
    .into_iter()
    .fold(Router::new(), |acc, (name, method)| acc.route(&canonical_path(name), method))
    .route_layer(from_fn_with_state(state, gate));

明天新增的路由因為在清單裡而被保護,不是因為有人想起某個慣例。

刻意建成獨立的 sub-router 再 merge——route_layer 只蓋這些路徑,
不會誤蓋 serve.rs 裡已經有自己(形狀不同)檢查的手寫路由。

本機完全不受影響:loopback 維持一致的豁免,實測 16 條 GET 加 POST 從
127.0.0.1 全部 200。


最後一個發現,最不需要寫程式

去看綁定到底是誰決定的:

~/.spectyn-mesh/agents.toml     host = "127.0.0.1"
launchd plist                   --host 0.0.0.0

我自己的設定檔說只綁本機。是安裝的 launchd 單元用命令列參數推翻了它。

而且:沒有設定任何 peers、沒裝 tailscale、7878 上沒有任何已建立的遠端連線。

今天沒有任何東西依賴那個 LAN 綁定。

程式碼裡甚至有一行註解寫著預設值該是什麼:

Secure-by-default: "auto" binds loopback + tailnet only (not raw LAN).

安全的預設值一直都在。是部署把它蓋掉的。

而這件事——一個和程式碼無關、只存在於一份 plist 裡的決定——
是今天所有安全問題裡影響最大、也最容易修的一個。

它也解釋了為什麼我前面二十條的曝光「感覺沒那麼嚴重」:
在正確的綁定下,那二十條根本不會被外面碰到。
但那不是它們安全,只是門暫時關著。


途中撞到的:--help 會執行命令

修完閘門,我順手測了佇列尾端那兩個小問題,結果撞到一個比它們嚴重得多的:

$ spectyn note "這句不該被寫進去" --help
✓ 已記錄筆記(aac13b88…)

$ spectyn focus start --minutes 25 --help
● 專注開始 — 25 分鐘 · session 01a040ff

你打 --help 想知道一個命令做什麼,它就做給你看。

根因是同一個,深一層:note 只檢查 args[2],所以 note --help 印說明,
note "text" --help 直接穿過去寫入。跟我當天上午在頂層修的一模一樣。

測試不看說明,看性質

我沒有去檢查說明輸出對不對。我測的是真正重要而且好陳述的性質:

--help 跑完,資料根必須逐 byte 相同。

然後配一條負控制——同一條探針拿掉 --help 必須真的改變狀態,
否則它偵測不到它存在要偵測的 bug。

負控制當場就抓到 event capture 是無效探針:它根本不寫本機,
而是 POST 到 daemon。


而那件事讓我發現:我污染了操作者的真實資料

測試污染解剖

event capture 走 HTTP 到 127.0.0.1:7878,所以它忽略 SPECTYN_HOME
我的測試探針寫進的是真實的事件庫。

我去看,八筆事件裡七筆是我的測試垃圾:

941a69ec  [text]  analysis skipped: no LLM provider configured
34cf2fa1  [text]  probe
61ec75a5  [text]  probe
...
3b701ef9  [text]  S0 驗收:封存舊資料根、修好空轉哨兵、重建事件儲存區   ← 只有這筆是真的

那些 probe 不是 event capture 寫的,是我的閘門測試寫的。
它 in-process 呼叫真的 handler,handler 從環境解析資料根,
cargo test 不設 SPECTYN_HOME —— 於是在閘門還沒加上去、路由還回 200 的
那段時間,它把四筆 "probe" 寫進了真實的生活紀錄。

一個 in-process 測試碰到真實檔案系統,不管它讀起來多乾淨,都不是隔離的。

七筆逐一刪除,測試改用 env_lock + SPECTYN_HOME 隔離(照 auth.rs 既有做法),
event capture 從探針清單移除並寫明理由。


還有一個:golden 的失敗路徑本身是壞的

檢查器的檢查器

跑 golden 的時候它直接爆了:

scripts/golden-cli.sh: line 163: want?: unbound variable

那行是:

echo "✗ spectyn $c --help(退出碼):快照 $want,實際 $rc"

$want 後面直接接全形逗號。shell 把多位元組字元一起吃進變數名,
set -u 於是說「未綁定」。

而它之前每次都全綠,所以這一行從來沒有被執行過。

也就是說:golden 報告「退出碼不符」的能力,一直是壞的,而沒有人知道——
包括我,我還在文章裡誇過它。同樣的寫法另有一處,一併修了。

順帶校正它檔頭一句過期的註解(「14 個子命令的 --help 不回 0」——實測 43 個全部回 0)。
一句沒人回去改的註解,跟它警告的漂移是同一種東西。


複檢清單剩下的三項

codex 那份回答很長,我先挑了最重的一條(bind 政策)。剩下的裡有三項值得做:

一、CORS preflight 會不會被我的閘門擋掉。

這是潛在的功能回歸——瀏覽器在真正請求前會先送一個不帶憑證OPTIONS
如果閘門在那裡回 401,合法的遠端 UI 會在還沒機會驗證之前就失敗,
而症狀(console 裡一個 CORS 錯誤)指向的地方離原因很遠。

實測沒有:preflight 回 200,閘門沒攔。

但我第一版的斷言寫成 != 401——那連 405 都會滿足,證明不了任何事
改成 == 200

二、to_bytesErr 不一定是「太大」。

我一律回 413。底層讀取失敗(傳輸中斷、解碼錯誤)也被報成「body 太大」,
等於叫呼叫端把一個從來就不大的 body 改小。改成看 Content-Length 分流。

三、/rpc/* 完全不在這層裡,而且從來沒有人盤點過。

這條最刺。我寫了「命令表的 18 條路由全部上閘門」,也量了 /api 的 30 條——
但「我們把 API 上閘門了」是一個關於單一前綴的說法,另一個前綴就在旁邊,沒被量過。

把盤點擴到 /rpc

routes probed               : 55
gated (401/403)             : 33
answered without credentials: 22

/rpc 的 25 條裡有 23 條是有閘門的——比 /api 好得多。開著的兩條是
/rpc/ping(活性探測,可議)和 /rpc/peers(交出 peer 清單,
跟我在 mesh 組用去指紋關掉的是同一種偵察洩漏)。

兩條併進債的清單,所以棘輪和「拒絕綁非 loopback」的政策一起涵蓋它們。


今天的規律,第五次

回頭數,同一個形狀今天出現了五次:

看起來 實際上
capture --help CLI 在回答你 它在猜,而且收費
我的驗證腳本 修正沒生效 量測壞了
golden master 行為變了 是我用了那個工具
note --help 在說明 在寫入
golden 的失敗路徑 一直全綠 那一行從來沒被執行過

加上前一天 S0 那條「回顧說今天沒事,其實是儲存區不見了」,是六次。

訊號本身在說謊,而每一次它都長得像好消息。

這已經不是一串巧合了。所以今天加的每一條檢查,我都先把它弄紅一次——
不是儀式,是因為一條沒被證明會紅的檢查,跟一條不存在的檢查,
在綠燈上看起來完全一樣。


進到「可安裝」,第一個障礙不是打包

佇列清空之後進下一階段:一台全新機器裝得起來、而且拿得到第一個回應。

我先去讀 E007-release-smoke.md——那是這個專案自己寫的煙測 runbook。
它的前置條件第二條寫著:

不要對操作者的 live ~/.spectyn-mesh 做煙測(D24 教訓)。
SPECTYN_HOME 是規格上的資料根覆寫,但尚未被一致地遵守(issue #322)——
不要只靠它做隔離。

那正是我今天早上踩的坑。

知識早就寫在這裡了。它甚至有編號。只是程式碼沒有強制它,
所以四筆測試事件寫進了我的真實生活紀錄。

量三次才量對

量三次

第一次我 grep dirs::home_dir()grep -v test,得到 66 處。
那只濾掉「含 test 字樣的行」,不是排除測試模組。

第二次寫了計數器排除 #[cfg(test)],得到 24 處。
但它數的是文字出現次數——skill_wire.rs 光是解釋「為什麼這裡用它」
就有兩段文件註解,全被算進去了。

第三次排除註解行,得到 16 處。這才是真的。

而 16 是可以在一個上午還完的規模。前兩個數字會讓人覺得這是個大工程,
然後往後排。量錯的規模,跟量錯的方向一樣會害人。

分兩類,不一刀切

類別 處數 做法
真的資料根 2 config.rsagents.toml ← 裡面有金鑰)、evolve_goals.rs(目標佇列)。改走解析函式
正當的 OS home 12 LaunchAgents/systemd unit 路徑、shell 的 ~ 展開、終端機預設 cwd、走訪目錄的邊界

第二類不該跟隨 SPECTYN_HOME——那是資料根,不是家目錄。
但它們仍然改了:裸的 dirs::home_dir() 在 Windows 上會忽略 $HOME
那會同時弄壞測試和設了環境變數的 service manager。

語意不動,機制改對。


修好隔離之後,兩件被它遮住的事浮出來

一、我早上那個 golden 修正只做了一半。

我讓它跑在一次性資料根上,寫進文章,還誇了它。

設定仍然洩漏——快照裡記著我真實的四個 provider:
gemini, groq, local-ollama, opencode

輸出看起來完全合理,所以我沒有再看第二眼。

二、spectyn doctor --help 根本沒印說明,它直接跑診斷。

而我那條「已派工的命令 --help 要退出 0」的斷言之所以是綠的,
正是因為隔離壞掉——hermetic 的測試其實讀到了我的真實設定、
找到四個 provider、於是退出 0。

修好一個洩漏,才看見另一個。


紅示範花了三次才做對

還完債之後我寫了一條測試:跑一批煙測會跑的命令,
操作者的真實資料根必須逐檔雜湊完全相同。

依母規則去證明它會紅——把 note 的路徑退回繞過解析函式的寫法。

測試通過了。 兩次。

第三次我插了樁:

[PROBE-BROKEN] note data dir = /Users/markl/.spectyn-mesh
✓ 已記錄筆記(97243f36… · 已加密)
  真實根 1 → 2

破壞是有效的。問題在前兩次那個破壞根本沒進到執行檔——
cargo test 沒有在跑測試前重建 binary,我測的是舊的那顆。

我為了證明一條測試會紅,做了兩次無效的示範,而兩次都看起來像成功

這是今天第三次「紅示範本身是壞的」(前兩次:附加在 #[cfg(test)] 之後、
去指紋守衛遍歷自己的清單)。

修好之後測試精確指出多了哪兩個檔。示範洩漏的兩筆事件已刪除。

而它只能偵測,不能阻止

這一點寫進了測試的文件:證明它有效就得跑一次壞的建置,
而那一跑會先寫進去才觸發斷言。

如果它哪天真的紅了,預期要手動清理那次跑寫進去的東西。


runbook 的警告,依實測更新

原文說「不要只靠 SPECTYN_HOME」。現在 CLI 端可以了。

但我沒有把那段刪掉改成「現在安全了」——我把剩下的例外寫出來

spectyn event capture POST 到 127.0.0.1:$SPECTYN_PORT 的 daemon
而不是寫本機,所以 SPECTYN_HOME 隔離不了它——事件會落在
跑著的 daemon 擁有的那個庫。煙測前要把 SPECTYN_PORT
指向一次性的 serve,或先停掉 daemon。

一句「現在安全了」會讓下一個人踩我今天早上踩的坑。
部分修好的東西,要說清楚修好的是哪一部分。


做出 RC 產物,然後拿它去問一台空機器

隔離修好之後,才輪到打包。scripts/build-mac.sh 本來就寫得很仔細——
它知道 cp 會剝掉簽章,然後 amfid 會靜默 SIGKILL,所以它在來源路徑就簽好、
再用 5 秒的 launch smoke 驗一次才交付。

✓ dist/spectyn-aarch64-apple-darwin (34M)

然後用它 + 全新資料根,量 S2 的出口條件:一台全新機器拿不拿得到第一個回應。

拿到了。27 秒。前一天 S0 修的本機模型 fallback 有效:

提示:沒有 agents.toml — 改用偵測到的本機模型(ollama / qwen2.5-coder:7b)。

而那個回應在我的 repo 裡寫了一個檔案。

[done] file_write → Written 43 bytes to .../src/main.rs
{"name": "git_add", "arguments": {"path": ".../src/main.rs"}}

我下的提示是「只回三個字:你好世界」。本機模型決定寫一個 Rust 檔然後 git add。
src/ 整個目錄是它憑空建的——這個 repo 的程式碼在 core/src


出口條件滿足了,而且不夠

RC 空機器

S2 的出口條件寫的是「全新機器拿到第一個回應」。

條件滿足了。 而它遠遠不夠。

一個滿足「拿到回應」但會在你所在目錄寫檔案的冷啟,比拿不到回應更糟。
拿不到回應你會知道,寫了檔案你不會。

這件事讓我對「可量的出口條件」這條鐵律有了一點修正:
可量不等於量對了東西。一個數字能通過,不代表它問的是對的問題。

為什麼沒有被擋

同一次執行裡 doctor 就說了:

✓ project trust: this directory is untrusted — enforcement off (informational)

打勾。因為在它的定義裡,「enforcement off」是一個正常狀態

pub enum TrustPolicy {
    /// No enforcement — trust is observed/reported but never restricts (default).
    #[default]
    Off,
    Prompt,   // 未信任目錄:Allow 降級成 Ask
    Observe,  // 未信任目錄:夾到唯讀
}

機制蓋好了。而 project_trust.rs 的模組檔頭自己描述了這個威脅

如果你 cd 進一個你不是自己建立的目錄——剛 clone 的 repo、下載的專案、
別人傳給你的資料夾——那個目錄可以用兩種方式攻擊你:設定劫持、以及 prompt injection。

然後預設是 Off

我去查了 BIG-GOAL.mdOPERATING-STANDARD.mddocs/specs/——
沒有任何地方把「預設關閉」記成一個刻意的取捨。

它不是被決定的,它只是沒有被決定。


這個我沒有順手改

Off → Prompt 會讓每一個現有使用者、每進一個新目錄都被問一次。
那是別人的日常,不是我的技術判斷可以代替的。

所以我停下來問,然後照裁決做:只改「完全沒有設定檔」那條路。

pub(crate) fn resolve_trust_policy(configured: Option<&str>, any_config: bool) -> TrustPolicy {
    if let Some(p) = configured.and_then(TrustPolicy::from_slug) { return p; }
    if any_config { return TrustPolicy::default(); }   // 有設定檔 → 完全不變
    TrustPolicy::Observe                                // 冷啟 → 夾到唯讀
}

agents.toml 的機器完全不變,包括明文設成 off 的。
改的是冷啟,不是每個人的日常。

同提示、同產物,重測

[denied] untrusted project (trust enforcement=observe): 'web_search' blocked
         — run `spectyn project trust add` to allow it here
→ exit 0 · 目錄檔案數 0 → 0

仍然拿得到回應,零檔案寫入,而且訊息直接告訴使用者怎麼放行。

一條防線做得太緊,會被關掉

我另外配了一條測試,釘住 Observe 仍然可用:讀取通過,
寫入/建置/raw-exec/網路擋掉。

因為一個連讀都擋的「唯讀」會讓全新安裝變得沒用,然後被使用者關掉——
那比沒有這道防線更糟,因為它還騙你以為有。

沒有一併處理的

web_search 屬於 NETWORK_TOOLS,所以在未信任目錄被擋。

對一個可能帶著 prompt injection 的目錄來說,擋掉對外送出是合理的——
那正是外洩的管道。但它確實讓冷啟的能力變窄了。

要不要在 Observe 底下細分「唯讀的網路查詢」和「會送出資料的網路動作」,
我留待日後,並且把這個取捨寫下來了。沒有處理的事情要留下名字。


派複檢,它指出我修的東西只保護第一次

冷啟的信任修好之後,我把 diff 丟給 codex。它的總判斷是
「diff 忠實實作了裁決,沒有寫歪」——然後指出裁決本身有個洞。

沒有 agents.toml       → Observe
建立任意 agents.toml   → Off

只要 onboarding、設定模型、或設一把 API key 順手寫了一份沒有
[trust].enforcement 的設定檔,下一次執行就無聲地從唯讀變回完全開放。

它的原話:

使用者沒有做任何信任決定,權限卻改了。
安全改善只保證第一次呼叫。

我沒想到。而且這不是實作 bug——是我把安全邊界綁在「檔案存不存在」
於是造出一個很難解釋的懸崖。

但我沒有自己去改裁決

裁決是「不改有設定檔的機器」。改那條線是使用者的決定,不是複檢的,
也不是我的。

所以我改的是讓它不再無聲

⚠ project trust: enforcement is OFF and this directory is untrusted —
  tools may write and run here. Nobody chose this; [trust].enforcement is
  unset in agents.toml. Set it to `observe` or `prompt`, or
  `spectyn project trust add` to trust this directory.

「Nobody chose this」那句是刻意的。它跟「你設定成關閉」是兩件完全不同的事,
而在這之前它們長得一模一樣。

一次啟動一次,不是每次呼叫——沒有人能行動的警告會變成雜訊,
然後跟沒有警告一樣。


兩件記下但沒做

一、錯誤訊息只給了一條路。

被擋的時候訊息是「run spectyn project trust add」——
那是最省事、也最全面的解法。使用者會直接複製。

codex 建議分級:單次批准 → 只信任目前的 canonical project root →
維持 observe → 最後才是全域關閉,並清楚標示風險。

它說得很白:「使用者往往會複製最省事的全域 off。」
一道防線如果只提供「整個關掉」這個出口,那它遲早會被整個關掉。

二、Observe 底下應該細分網路動作。

完全不限制網路不安全——模型可以把剛讀到的本機內容塞進 query 送出去,
那正是外洩管道。但把網路查詢和寫檔一起說成「唯讀限制」也不合理,
而且 blanket deny 很傷首次體驗。

兩件都寫進了發現文件。沒有處理的事情要留下名字。


而 install.sh 自己也繞過了資料根

資料根棘輪

DATA_DIR="$HOME/.spectyn-mesh"

#322 我掃了 Rust,沒掃 shell

所以 SPECTYN_HOME=/tmp/x ./install.sh 會在錯的地方建目錄,
而真正被用的那個根一動也沒動。

改成 ${SPECTYN_HOME:-$HOME/.spectyn-mesh},實測資料根現在建對地方。

一次「還清了」的宣告,範圍要講清楚。我說的是 Rust 那 16 處,
不是所有會拼路徑的地方。


一個沒成真的擔憂,也要講

我原本懷疑 install -m 0755 複製執行檔會剝掉 macOS 的簽章
然後 amfid 靜默 SIGKILL——build-mac.sh 的檔頭就是為了這個才在
來源路徑先簽好。

實測沒有成真:cargo 在 Apple Silicon 上會自動 ad-hoc 簽章,
裝好的執行檔 --version 正常 exit 0。

寫下來是因為——一個查過而且沒問題的疑慮,跟一個沒查過的疑慮,
在文件上看起來一樣。
下一個人不該再查一次。


一個我修不了的

runbook 的 Step 0 把這個列為主要安裝路徑:

curl -fsSL https://spectynmesh.io/install.sh | sh

install.sh 自己第 11 行寫著:

(curl .../install.sh | sh) is a FUTURE release-pipeline item and is not wired

兩份文件在同一個倉庫裡,一份說這是主要路徑,一份說它不存在。

這個需要主機端,不是我今天能修的。但它值得一個名字,
因為煙測 runbook 照著跑會在 Step 0 就卡住——
而那正是「一台全新機器」最先碰到的一步。


最後去看了那個「已知浮動」

一整天,每次跑完全套測試我都要多做一件事:把失敗名單跟 HEAD 比對,
確認「這 3 個(有時 4 個、有時 5 個)是已知浮動,不是我弄壞的」。

一個隨機失敗的測試套件,本身就是會說謊的訊號——而我整天都在為它付利息。
所以下午終於去看。

先量,不猜

連跑五次 skillbank,失敗名單:
  第 1 次: search_matches_inserted_token, ingest_batch_...
  第 2 次: search_matches_inserted_token, ingest_batch_...
  第 3 次: insert_seals_text..., search_matches_inserted_token
  第 4 次: delete_sealed_row..., search_matches..., ingest_batch_...
  第 5 次: delete_sealed_row..., search_matches..., search_with_escaped..., ingest_batch_...

名單每次不同——但 search_matches_inserted_token 每次都在
單獨跑它:過。--test-threads=1 跑整個模組:54/54 全過

不是邏輯 bug。是平行競爭。

根因:一個全域,三把鎖

鎖的洋蔥

共享的是 SPECTYN_ENCRYPT_MEMORY(行程全域的環境變數)和
EVENT_KEY_CACHE(行程全域的金鑰快取)。而守著它們的是:

模組
skillbank::memory 自己的 SEAL_TEST_LOCK
skillbank::memory_seal 自己的另一把 LOCK
skillbank::sync crate 層級的 env_lock

三個模組,三把不同的鎖,守同一個全域。它們彼此完全無法互斥——
只能排除自己人。

而且第一把還有第二層問題:註解寫著「every flag-ON test serializes
on this mutex」——只有寫入方拿鎖,讀取方不拿。單邊拿的 mutex
不提供任何互斥:一條 flag-OFF 的測試可以在別人把 env 設成 ON 的時候跑,
它的插入被加密了,搜尋自然找不到。

修法乏味而正確:全部統一到 crate::env_lock,讀者跟寫者一樣拿。

修好一個,露出下一個

skillbank 安靜下來之後,全套還是偶爾紅——這次是 identity_wire
兩條 keychain 測試,MasterNotFound:自己剛寫進去的,立刻讀不回來。

帳號名有 pid + uuid,不可能撞名。單獨跑 4/4 全過。
變因是對 macOS Security framework 的並行存取。序列化之後消失。

它一直躲在 skillbank 的雜訊後面。一個浮動遮住另一個——
這就是「known flaky」這個標籤昂貴的原因:它讓人不再去看。

誠實的數字

修完之後,全套連跑 17 次:16 綠,1 次失敗(那一次沒留下名字)。

從「每次 3–5 個失敗、名單天天換」到 16/17。量級改善——
但我不寫「根除」。殘留一個低頻競爭,等它再現時,至少現在的套件
安靜得足以讓它有名字。


收掉 --help 的最後兩個

一整天下來,--help 被錯待的形式收集了七種。最後兩個:

spectyn doctor --help 跑了診斷。

「doctor 是做什麼的」這個問題,得到的回答是一個描述機器健康的退出碼——
在全新機器上,那個答案是 2

而它的能見度本身有個故事:這個缺陷是 SPECTYN_HOME 隔離修好之後才看得見的。
在那之前,hermetic 測試讀到我的真實設定、找到四個 provider、退出 0——
測試綠,是因為隔離壞掉。修好一層,才看見下一層。

spectyn -q --help 開了互動 session。

根層的 help 檢查只比對 args[1]。旗標開頭的形式落空,掉進 session——
「要說明」變成「啟動一個 UI」。

最有意思的是,處理這件事的那段守衛,註解裡記著一次真實事故:

a real incident: logout --help once wiped the operator's auth.json

名單早就在了。 有人被咬過,寫了守衛,列了十二個危險的子命令。
只是「旗標開頭」這個形狀不在名單上——名單永遠會少一個形狀。
這正是 Q1 那條「與名單無關」規則存在的理由,今天在另一層又證明了一次。

七種形式,一句不變量

七種求助形式

capture --help        → 燒一次 LLM 呼叫        (Q1)
banana --help         → 燒一次 LLM 呼叫        (Q1)
note "x" --help       → 寫入一筆事件           (help_never_mutates)
focus start --help    → 開始 25 分鐘計時       (help_never_mutates)
event capture --help  → 寫進 daemon 的事件庫   (help_never_mutates)
doctor --help         → 跑診斷,exit 2         (今天最後修)
-q --help             → 開互動 session         (今天最後修)

「這是什麼」永遠不可以變成「做這件事」。

一句話的不變量,七個藏身處。每一個都有一條會紅的測試看著了。


懸了兩天的案子:75 次接觸的那條迴圈

斷路器

測試套件安靜下來之後,回頭處理懸案。紅測試釘著的數字:

一個 turn,死 provider 被接觸 75 次,花 75 秒

兩天前我插樁過 call_with_streamingcall_with_fallback,零輸出——
於是記成「不是這兩條,存在第三條迴圈」,並且照這個結論去讀了很多程式碼。

重新量,結論反了

今天不讀碼了,直接三點插樁(env-gated),跑紅測試:

25 [LOOP-PROBE] call_with_fallback entered
   DEAD ENDPOINT CONTACTED 75 TIMES

就是 call_with_fallback。進入 25 次。

前一輪的「零輸出」十之八九是插樁根本沒進到執行檔——
跟今天稍早紅示範沒 rebuild 是同一種失誤。我根據一次壞掉的量測,
記下了一個錯的結論,然後帶著它讀了兩天的程式碼。

放大器不是重試,是回合run_inner 每個工具回合都重走整條 provider 鏈。
上一回合才確認死掉的 provider,下一回合又從頭試。
25 回合 × 3 次網路重試 = 75。

修法:斷路器綁 runtime,不綁行程

兩天前試過一次全域斷路器——別的測試的死端點把「開路」狀態
漏給下一個測試,不相干的 skill_wire 因此多紅一個,整個 revert。

這次綁在 AgentRuntime 上:runtime 死,狀態跟著死,測試各自 hermetic。

接線刻意最小:兩個 provider 迴圈頭諮詢 allow()
generic HTTP 路徑三點記錄(網路錯誤與 429/503 計入開路、2xx 重置)。
訂閱 CLI 路徑和 serve/TUI 的第三條迴圈還沒記錄——寫進佇列,不順手。

結果

修前:DEAD ENDPOINT CONTACTED 75 TIMES   (75 秒)
修後:DEAD ENDPOINT CONTACTED 3 TIMES    (3 秒)

threshold 3 開路,其餘 24 回合零接觸。

紅測試的 ignore 字串自己寫著「解除 ignore 就是完工定義」——解除了,
它現在是常駐守衛。反向母規則:把諮詢改成 if false,立刻回到 75 次並紅。

這一段最值得留的不是修法,是那個帶著錯誤結論讀了兩天程式碼的教訓
量測工具自己要先被驗證。一次「零輸出」在說的可能不是「這裡沒有」,
而是「你的探針沒裝上」。


反思段裡的那坨 JSON

早上 S0 驗收時,我的真實每日回顧長這樣:

### Daily alignment
{"name": "alignment reflection", "parameters": {"input": " quiet day no interactions…"}}

本機模型對「溫暖地反思今天」這個提示,回了一個捏造的工具呼叫

有趣的是防線的分工:salvage_tool_call_from_text(Day 2 加的)正確地
沒有執行它——名字不在註冊表。但沒有任何東西阻止這串 JSON 被當成
散文呈現出來。

而反思是整個系統裡唯一一個輸出被人當成溫度來讀的地方。
一坨 JSON 出現在那裡,比誠實的跳過更糟。

修法三句話:判斷輸出是不是 tool-call 形狀(刻意與註冊表無關——
不管模型把工具叫什麼,散文它都不是)、是就走既有的 (skipped: …) 降級、
不執行不呈現不捏造。

測試用的就是今早那串真實字串,加三句散文反例——包括談到 JSON 的
和含大括號的,因為誤殺真散文的守衛會被關掉。


順帶修正一條我自己寫錯的紀錄

上一段 commit 我寫「serve/TUI 用 streaming.rs 的第三條迴圈,記錄未接」。

查證後是錯的:serve 與 TUI 全部走 AgentRuntime::run → 已被 breaker
涵蓋。streaming.rs 那條 'rounds 自由函式迴圈只有測試在呼叫——
它是這棵樹的第四條死鷹架(前三條:providers_wire、其 circuit_breaker
接線、is_served 的舊名單)。

死鷹架的可怕不在它占空間,在它長得像活的:我兩天前就是把斷路器
接進了其中一條,綠了,而正式碼一次都沒走過它。


拿 selftest 量「全數功能正常」——69/16 到收斂

selftest 收斂帳

下午的指令改成「持續邊重建邊紀錄,直到全數功能跟介面正常」。
最直接的量法:跑產品自帶的 spectyn selftest

第一次:69 pass / 16 fail。逐一驗完,16 個失敗分成五種病:

一、我自己的呼叫方式污染了它(9 個假紅)

我帶著 SPECTYN_HOME 跑 selftest——它洩給每個場景和 cargo test 子行程,
把場景自建的 HOME= 隔離全部蓋掉:43 個單元測試、三整組功能因此紅,
而功能全是好的。selftest 現在會 shed 掉外來的覆寫(它有自己的隔離策略),
而那行 note 放在參數解析之後——因為 golden 立刻抓到 --help 多了一行:
說明路徑不准長出副作用,今天自己定的規則自己差點破。

二、serve 場景撞上我早上的 bind 政策(2 個真互動)

這台機器其實 tailscale 位址(app 在跑、CLI 不在 PATH——我早上
「未安裝」的結論只對一半)。host=auto 解析出 tailnet 位址,被「還有
無憑證路由就拒綁非 loopback」擋下,serve 開機即死。政策不變;場景改成
明確 --host 127.0.0.1(它們本來就只打 localhost),拒絕訊息補上引導。

三、場景是化石(4 種)

  • autoevolve --dry-run:這個旗標從來不存在,場景一直紅得像功能壞掉
  • cluster-rpc 用 openssl 對 raw body 做 HMAC:server 早已是 canonical v2,
    第二份手寫簽章器漂了。改用產品自己的簽章器(spectyn peer assign),
    永不再漂——artifacts 證實過 auth、在 provider 層如預期失敗
  • dashboard 釘「恰好 6 個專案」:衛星清單長到 11,數字化石。改錨定
    「非空且含 spectyn-mesh」
  • coldstart e2e grep 英文字樣:zh 機器輸出中文,功能過、測試紅。釘 LANG=C

四、timeout,第三次

run 場景 exit 127 = macOS 沒有 timeout30-mcp.sh 早就解過一次,
解法留在單一場景沒共用。提升成 selftest.sh 的 t_timeoutexport——
沒 export 時場景子行程照樣 127,量過。

五、真正的產品修正(3 個)

  1. salvage 加 function_name/parameters 別名:實測 qwen2.5-coder
    吐這種拼法,鍵不被認,工具沒執行,probe 紅得像管線壞掉
  2. SPECTYN_PORT 讀寫不對稱:help 說它「覆寫 serve 的 :7878」,serve 認,
    detect_listen_port() 不認——用它換 port 的 daemon,同機的
    spectyn sessions 反而找不到。這是從一條測試讀到我 24 個真實 session
    挖出來的:測試假設「沒有 serve 在跑」,而我的 daemon 就在 7878
  3. file_read probe 判準改為可歸因:7B 模型每次用新的化裝講解工具而不是
    呼叫。工具有發而答案沒回 → fail(管線主張);連嘗試都沒有 → 誠實 skip。
    怪管線就是冤枉它,而冤枉出來的紅會被靜音。

收斂

69 pass / 16 fail / 3 skip   → 86 pass / 1 fail / 4 skip

最後一項待終驗。skip 都是誠實的(無 hf CLI、模型不配合、快照工具缺)。

終驗

summary  87 pass, 0 fail, 4 skip  (658s)

selftest 首次全綠。 最後兩片:launchd 的時間斷言是 Linux 形狀的期望
(override 在 mac 上有效,只是 plist 把 04:30 拆成 Hour/Minute 整數,
字面比對紅在格式而不是行為);以及失敗計數的 grep 把 test result: FAILED.
摘要行也數進去,每個失敗 target 虛高一。

從 69/16 到 87/0,一個下午。而其中真正的產品缺陷只有三個
(salvage 別名、SPECTYN_PORT 不對稱、bind 政策與 auto 的互動)——
其餘十三個全是量測自己壞了:化石場景、化石斷言、洩漏的隔離、
缺 export 的 shim、虛高的計數。

這大概就是今天最後的一課:一個說「16 個功能壞了」的儀表板,
實際上是 3 個功能問題加 13 個儀表板問題。
修儀表板跟修功能一樣是工程,而且往往得先做。


複檢對 87/0 的判詞:「乾淨的儀表板,不足以自證」

我把這批收斂丟給 codex,連同我的分類:「13 個量測問題、3 個產品缺陷」。

它的回覆值得整段留下:

「13/3 這個精確說法有粉飾風險。--help 因 shed note 多出副作用輸出,
使用者可見的 CLI 行為缺陷——即使最先由 brittle assertion 發現。
測試程序竟能讀到操作者 24 個真 session,也不應只寫成『假紅』便結案。
『化石場景』只有在舊場景確實已被新契約取代時才是量測問題——
如果只是產品不再滿足舊需求,然後順手把測試改掉,那是在藏回歸。

它說得對。更誠實的分類是:

類別 數量
核心功能缺陷(salvage 別名、SPECTYN_PORT 不對稱、bind×auto) 3
使用者可見的 CLI 行為缺陷(--help 副作用行) ≥1
測試契約/隔離缺陷(洩漏的隔離、缺 export 的 shim) 若干
純報表缺陷(虛高計數) 1

「3」那個數字太乾淨了。乾淨的數字最好聞一下。

三個具體收緊也照做了:file_read 拆成「確定性介面 probe(硬斷言,nonce
fixture)+ 模型選工具(標 INCONCLUSIVE)」——拆完第一跑就有收穫:
fixture 放錯位置被工具沙盒正確拒絕,紅在一個在做事的沙盒上;
launchd 斷言收成同一 dict 內的相鄰結構;dashboard 斷言補上唯一性與前綴。

順帶第八個發現:spectyn tool 讀檔失敗印 Error 卻 exit 0。進佇列。


終驗

含今天全部修正的最終全套:

summary  95 pass, 1 fail, 3 skip  (524s)

那 1 個 fail:FTS5 的 p99 延遲測試,在我旁邊並行編譯的負載下超了
200ms;單獨重跑 12 秒綠。效能斷言在共享負載下的假紅——不是退化。

而它的報告寫著「0 test(s) failed (exit 101)」——昨天修虛高計數的
那刀,字元類別漏了數字(測試名裡有 200ms),把計數往 0 偏。
同一天裡,修膨脹的修正造出縮水的。 補上 0-9

三個 skip 都有名字:hf CLI 未安裝、mobile 衛星未 clone、
7B 模型的 tool-choice 標 INCONCLUSIVE(介面本身由確定性 probe 硬斷言覆蓋)。

從 69/16 到 95/1(且那 1 個無干擾下是綠的)。
spectyn tool 的退出碼也修了——失敗印 Error 卻 exit 0 的日子結束,
四種失敗形狀紅測試先行。

今天在此收工。


晚間第二輪:安裝→LLM→app 的整條鏈(G1)

set-key

晚上的指令變成一條完整的驗收鏈:安裝、導入雲端 API、本地模型、
實測、操作者複測、最後 app 端可用。第一段(G1)當晚做完,
它挖出的東西比預期多。

表裡宣告了三天的命令,不存在

provider.set_key 在命令表裡 cli: true 掛了三天;doctor 的修復提示
甚至叫使用者跑 spectyn provider add——兩者都不存在
把 key 弄進一個全新機器的路,只有瀏覽器精靈或手編 toml。
艦隊不可能經瀏覽器導入。

新面一條:

spectyn provider set-key <name> --env VAR          # 只記變數名
echo "$KEY" | spectyn provider set-key <name> --key-stdin --model M

--key <值> 直接拒絕——argv 對整台機器的每個行程可見,還進 shell history。
值不回顯;toml 原地編輯,別的段落逐 byte 保留。

然後一路撞出四個缺陷

G1 四缺陷

一、我寫了 loader 不認的鍵。 ProviderEntry
#[serde(rename = "type")],我寫 provider_type——
providers list 對著一個明明寫著型別的檔案顯示 <no type>
結構體的欄位名不是線上格式的鍵名。

二、部分設定比零設定更糟。 零設定的機器,exec 會合成 local-first
agent、能答;set-key 寫入一個 provider 之後,同一台機器反而死在
No agent configuration found加了一把 key,機器變得更不能回答了。
修法:有 providers 無 [agent] 時合成預設,走既有解析鏈。

三、doctor 指路指向不存在的指令(見上)。

四、你日常設定裡的 gemini 其實早就壞了——gemini-2.0-flash 已退役,
404。沒有任何東西告訴過你。不動真實設定,記下來。

端到端的誠實判定

全新根、真 key 程序內轉移(不落 argv、不印出):

groq   → HTTP 403 "check your network settings"   請求抵達 Groq,網路層拒
gemini → model 修正後 429 rate limit               連 401 都沒有:key 有效,額度滿

鏈完整證明(導入→設定→解析→真請求→上游明確回應);
「真實 200」被上游額度與網路擋住——那是環境事實,不是 CLI 的。
三家證據俱附,G1 以此收。


(Day 04 實錄至此完整收錄;工程檔案與測試見 repo。)


上一篇
Day 04(上):一個不准猜的 CLI — 訊號說謊、母規則與免費層的半衰期
下一篇
Day 05(上):一天,八個階段,29 個格子 — 從安裝到住進 mesh 的終端
系列文
從單一agent 到多agent 集群的開發流水帳以及應用18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言