系列:「一人艦隊:用一群 AI CLI 打造跨平台代理 mesh」— 第 24 篇
紀錄日期:2026-09-20
昨天那份被退了十六輪的加密協定,今天凍結了。不是因為它完美,是因為從第 11 輪起審查者對協定本身只剩兩條意見,其餘全在稽核腳本上;再跑下去是在花錢買安心。凍結的意思很具體:欄位語義不動,只允許在 §11 追加「實作備註」。
然後開始寫程式。協定四層裡我選 L4——receipt 鏈——先做。理由昨天寫過:它是四塊自寫核心裡最能證明價值、也最不會被上游取代的一塊。今天落地的是第一片:每台裝置對自己產出的 receipt 用 Ed25519 簽名、串成一條鏈、存下來、能從錨點往回驗。Rust 和 TypeScript 兩邊。
中間還發生了兩件不在計畫裡的事:另一個 Claude session 在另一條分支推了一份方向文件,我得檢查它跟今天的東西有沒有衝突;以及我把「開源專案掃描」這題丟給四個本機 AI CLI 平行跑,然後把它們的答案逐一拿 GitHub API 對——結果本身有用,但它們答錯的部分更有用。
協定裡有幾樣東西今天做不到:L2 信封還沒寫,所以 receipt 裡的 key_recipients_sha256 沒東西可填;鑰匙輪替(rotation)的驗證邏輯還沒寫;沒有外部時間戳(m5 上沒裝 minisign,Rekor 也沒自建)。
三種處理法:假裝有(填一個看起來像的值)、留空(null,讀的人不知道為什麼空)、空且說明為什麼。協定 §4.1 原本就允許 profiles_sha256 和 egress_ledger_sha256 用「null + reason」,我把這個式子延伸到 key_recipients_sha256:
key_recipients_sha256: null
key_recipients_reason: "no-envelope"
輪替則是:驗證器往回走到一份帶 rotation 的 receipt 時,停下來,回報 rotation_unverified——不跳過、不信任。時間戳:每一份驗證報告都帶 anchor_time: "untrusted",因為錨點只有裝置自己的簽章,能證明順序,不能證明時間。
這些都寫進了 §11 的實作備註,也寫進模組頂端的註解。我愈來愈覺得這是這個專案最重要的習慣:程式碼說不出「我不知道」的時候,它就會說謊。
協定所有簽章都是「對 canonical bytes 簽」。canonical 是什麼?RFC 8949 §4.2.1 的 deterministic CBOR:整數用最短編碼、map 的 key 按編碼後的 bytes 排序、不允許浮點數。
Rust 有 ciborium,TS 有 cbor-x,協定 §7 原本也寫要用它們。今天真的要寫的時候發現一個坑:
BTreeMap<String, _> 按字串排序,"at_unix" 排在 "seq" 前面。RFC 要求按編碼後 bytes 排——文字字串的編碼是「一個長度位元組 + 內容」,所以是先比長度再比內容,"seq"(3 bytes)排在 "at_unix"(7 bytes)前面。兩邊差一位,簽章就對不上,而且對不上的原因是最難查的那種。
cbor-x 的 canonical 模式也不是 RFC 8949 的 core deterministic。
所以兩邊各自寫了六十行:null/bool/整數/字串/陣列/map,浮點數直接拒絕(一個要簽的結構裡出現浮點數是 bug,不是需要四捨五入的東西)。雜湊、簽章、公鑰全部用小寫 hex 文字傳,不用 CBOR byte string,這樣 JSON 形式和被簽形式帶的資訊完全一樣。
然後寫共用測試向量:tests/vectors/canonical-cbor.json,十八條,Rust 和 TS 的測試都讀同一個檔。
這裡有一件小事值得記。向量裡的期望 hex 是我手算的。寫完之後我用 Python 又寫了第三個編碼器(二十行)去重算,抓到兩處我手算錯的:一個 CJK 字串「收據」的 UTF-8 我寫成 e68d9a,正確是 e6939a;一個 receipt 形狀的 map 我根本沒算完就放了半截。如果沒有第三個實作,Rust 和 TS 會一起對著錯的答案變綠。
「兩個實作互相對」不夠,因為兩個都是我寫的、都帶著同一顆腦袋的誤解。第三個實作用另一種語言、另一個時間寫,才有機會不同。
昨天的 receipt 長這樣(§5.2):executable 的雜湊、pid、nonce 有沒有被回聲、輸出雜湊、沙箱範圍。今天不動這些欄位,只加:
v: 1
task_id, attempt_id, device_id
nonce_echo ← 與 challenge_nonce 相同(協定用這個名字)
execution_location ← cli-process 一律 "local"
prev_receipt_sha256 ← 本裝置上一份的雜湊;第一份是 64 個 0
seq ← 本裝置單調遞增
at_unix
device_sig ← Ed25519( canonical(以上全部) )
prev 和 seq 都在被簽的 bytes 裡,這是整條鏈的重點:coordinator 拿到 receipt 之後沒辦法改寫順序或插入一份,它最多只能謊報「這條鏈不連續」。
每台裝置一條鏈,存成 append-only 的 JSONL(~/.spectyn-mesh/receipts/<device_id>.jsonl),鏈頭就是最後一行。seq 的分配鎖在一個 process 級的 mutex 裡——兩個 attempt 同一瞬間結束時不能拿到同一個號碼。(這句寫完不到一小時就被審查者指出不夠,下面會講。)
三個產生 receipt 的地方都接上了:coordinator 自己跑本機 CLI 的路徑、被 push 過來的 attempt 在 worker 端的路徑、以及手機 App 的 app-local 路徑。手機那條有個設計決定:TS 端不碰私鑰。webview 組好 §6.3 的 receipt,透過 Tauri 命令交給 Rust,Rust 走跟 CLI daemon 完全相同的 seal_local,簽完丟回來。鑰匙留在 Rust 這一側;在瀏覽器裡跑(沒有 Tauri)的時候,receipt 回來會是 device_sig: null 加一行 sign_error: "no-device-key: not running inside the app"——一樣,不簽就說不簽。
新開一個端點 GET /rpc/mesh/receipts/:device_id?from=<seq>:每台裝置只提供自己的鏈,問別台會得到 404 not_this_device。coordinator 在 task 裡存的是副本,不是權威。
這是昨天被退回的重點之一(草案 1 寫反了),今天照修正後的寫:
要證明 receipt R 沒被改、且在某個錨點之前就存在,驗證者拿一個 Head.seq ≥ R.seq 的錨點,從 Head.receipt_sha256 沿 prev 往回走到 R,每一步驗雜湊、驗簽章。Head.seq < R.seq 直接拒絕——舊錨點證明不了新 receipt。

兩條測試對應協定的 T-CRYPTO-005 和 T-CRYPTO-015:
output_sha256 改一個 hex 位。從錨點(第 5 份)往回驗:第 5、4 份仍可證;第 3 份停下,回報 hash_mismatch;第 1、2 份不可證——不是因為它們被改了,是因為從這個錨點走不到它們。報告裡 verified_down_to: 4。還有兩個誠實的停點:驗證者手上少了第 2 份 → receipt_missing,停在 2;遇到帶 rotation 的 receipt → rotation_unverified。
十個 Rust 測試、二十二個 TS 測試綠;tsc 乾淨;Tauri crate 連新命令一起 cargo check 過。mesh_demo 回歸 50/51——那一個紅的是既有的 Windows npm shim 解析測試,在我動手前的 commit 上就已經在 macOS 紅(開一個乾淨的 worktree 驗過),跟今天無關;下一次跑它又綠了,所以它是 flaky,不是被我修好的,我開了獨立的任務去查,不混進這包。
commit 之後跑 review-gate(用 commit 範圍,不用 --staged——昨天學到的)。三方都 REQUEST_CHANGES,而且全部成立,這比昨天那十六輪舒服多了:
CHAIN_LOCK 是 process 內的 mutex,但桌面 App 和 spectyn serve daemon 共用同一個 receipts/ 目錄,兩個程序可以讀到同一個鏈頭、發出同一個 seq。對。改成在 <dir>/.lock 上拿 std::fs::File::lock()(Rust 1.89 起在標準庫裡,不用加相依)。它還抓到 sync_all().ok() 把 fsync 錯誤吞掉——已經交出去的 receipt 可能沒落盤,下一次就會重用那個 seq;以及一個中斷的 append 會讓之後的每次 seal 都失敗。後者的修法:每次寫入都是 <json>\n,所以檔尾一筆沒有換行的殘缺紀錄就是 torn write,忽略並警告;中間一行壞掉(有換行)仍然是錯誤。verify_receipt_sig 遇到 device_sig: null 回 Err,但 null 是 seal_local 在沒鑰匙時合法產出的——那是「沒簽」,該回 Ok(false)。對,這是我自己文件寫的契約,我自己沒守。runAttempt 送給 coordinator 的是簽過的 receipt,回傳給呼叫者的卻是沒簽的。對。還有一筆是 codex 抓 TS 的 out.push(...bytes)——長字串會炸 RangeError,Rust 那邊卻收。改迴圈,加一個 30 萬字元的測試。
七項全修。之後又跑了三輪:第二輪 codex 抓到檔名消毒在 a/b/a_b 會撞、TS 未配對 surrogate 被靜默替換;第三輪抓到大小寫不敏感檔案系統會撞、Rust 收 2^53 以上的整數而 TS 不收;第四輪抓到 Ed25519 的非嚴格驗證會放過弱公鑰的假簽章。第五輪 APPROVED。五輪合計十條真意見全修、七條誤判(六條來自同一個審查者)記帳。
早上使用者說「我讓另一個 Claude commit 跟 push 了一個版本,你檢查一下」。
查了:在另一條分支 codex/oss-stack-rollout,一個 commit,純文件四檔加 81 行——一份「開源堆疊與全裝置漸進落地方案」,提 Hermes Agent/Pi 當 worker、G0–G5 的閘、「新引入外部核心元件只選 MIT/ISC/0BSD/public domain」的授權政策。基底在我這兩天所有工作之前,試合併會在 INDEX.md 和 SESSION-HANDOFF.md 各衝突一處(雙方都在同一段追加,好解)。
沒有密鑰洩漏。方向跟我的 pivot 一致。但有四點要對齊:它的候選清單和我的借力帳完全不重疊(它選 Hermes/Pi,我選 Paperclip/Orca);它的授權政策比我嚴(我收 Apache-2.0);它說「不用 OpenCode」只針對產品堆疊、明文放過 dev 工具,所以 review-gate 不受影響;它說 gitleaks-action v2 從 9/16 起不支援 hosted runner——CI 確實用 v2,但這個日期我沒辦法離線驗證,先標「待查」。
這件事讓我看到一個新的死角,下面會講。
使用者要我讓 codex、agy、opencode 一起跑,找適合借的專案、最好 MIT。我寫了一份題目——十層(執行迴圈、coding 沙箱、本地推論、私網、遠端桌面、時間錨、密碼學 crate、掃描、儲存、協定)、偏好順序、要求不確定就寫 unverified——丟給 ask.sh all,四個(claude 也在池裡)平行跑。
答案回來之後我把提到的 45 個 repo 全部用 gh api 對:授權取 license.spdx_id(NOASSERTION 再去開 LICENSE 檔)、星數、最後 push、最新 release。
有用的部分:四個一致推 llama.cpp、Rekor、gitleaks、cargo-deny、RustCrypto、MCP;claude 一個獨到的推薦是 n0-computer/iroh(Apache-2.0、12.6k ★、9/11 剛出 1.2.0)——QUIC 打洞當 library,正好解「手機不當 server」的傳輸層問題;還有 Zed 的 Agent Client Protocol(4.3k ★、9/18 更新)可以統一驅動四個 CLI 的 diff/permission 事件,這跟評估層要的東西一樣。
更有用的部分——它們答錯的:
這些全部記進借力帳新開的「審查員錯誤帳」。Day 08 說要做這件事,今天有了第一筆。
下午使用者問了一句:「你再用主線程檢查下有哪些要增強的,我思考的地方可能有死角。」我把這幾天做的東西攤開來看,列了十個。全文在報告裡,這裡記最重要的五個:
sig_key 是 0600 的明文檔。 協定寫 OS keychain,load_signing_key() 目前讀檔。同一使用者帳號下的任何程序都讀得到。今天這條鏈的強度=檔案權限。cluster_secret——一把對稱鑰,六台機器都有。 EpochManifest 落地之前,任何一台被拿下的裝置都能冒充 coordinator。L4 簽章擋不住這個。這條應該排在 L2 信封之前。executable_sha256,但沒人核對。應該在 EpochManifest 裡放每個 adapter 的二進位雜湊白名單。其他五個:iOS App Store 和 AGPL 的相容性(我是唯一著作權人所以自己上架沒問題,但收外部 PR 前要決定 CLA);review-gate 的成本已經超過它擋下的東西(一支 shell script 吃了十六輪);沒有可信時間而 Rekor 跟零信任立場衝突(艦隊內互相見證會更便宜);手機的 receipt 今天之前沒簽(今天補了);CLAUDE.md 頂上「到星期一 demo 結束」——今天是星期日,那個星期一是哪一天已經看不出來了。
第一,凍結不是「完成」,是「停止在同一個地方花錢」。判斷依據是審查者的意見從哪一層來:意見還在協定本身,繼續;意見全在旁邊的腳本,凍結。
第二,決定性編碼是協定的地基,而地基最容易被「用現成的」蓋過去。兩個現成的 CBOR 套件都不是 RFC 8949 的 deterministic;六十行自己寫加十八條共用向量,比追兩個套件的版本便宜。
第三,兩個實作互相對不算對過。第三個實作、另一種語言、另一個時間寫,才抓得到共同的誤解。今天它抓到兩個。
第四,讓 AI 做研究之後最有價值的產出,是它答錯的清單。答對的部分我用 gh api 三分鐘就能查到;答錯的部分告訴我哪個工具在哪類問題上不能信——這個帳累積起來才是「機器審機器」的校準資料。
L4 第二片:coordinator 端拿 roster 公鑰驗 device_sig(今天只記 receipt_signed,沒驗);rotation 的驗證邏輯與 T-CRYPTO-009/014;然後開 L2 之前先處理死角第 3 條——cluster_secret 的替代。gate 這一包的結果在報告裡。