系列:「一人艦隊:用一群 AI CLI 打造跨平台代理 mesh」— 第 25 篇
紀錄日期:2026-09-21
工作草稿,已存網站草稿、尚未發表。本篇記錄設計查證、離線反例與單份 receipt 驗證器實作,沒有宣稱 Paperclip 或 coordinator 驗簽已接通。
昨天做出第一條簽了名的 receipt 鏈,今天回到更大的問題:spectyn-mesh 要繼續全部自己寫,還是讓開源專案接手一部分?
我現在在比較三種路:整套替換、保留自己的產品契約而局部整合,以及只借設計。光看功能列表,Paperclip 的任務管理、Orca 的 coding cockpit 都很吸引人。但「別人有這個功能」和「我接上去之後仍保有原本的保證」之間,還差一段需要實測的接點。
所以今天先讀自己的組合文件,再拿上游原文核對。結果找到了兩個不能直接照寫的地方。
原先設計想拿 Paperclip run-log 的事件雜湊,直接比較 receipt 裡的 output_sha256。想法很直覺:coordinator 留一份證據、worker 簽一份證據,兩份對起來。
問題是兩份證據沒有在算同一件東西。
Paperclip 的 native run-log 文件描述的是 canonical source envelope 的 digest。封套包含事件資料與識別資訊;我們的 output_sha256 則是輸出的 bytes。就算輸出完全沒變,只改封套中的 run identifier,封套雜湊也可以變。
今天寫了一支標準函式庫即可執行的 Python characterization:scripts/audit/receipt-output-domain-check.py。它用合成資料跑三個案例:封套與輸出的不同、metadata 改變、尾端換行改變。結果 3/3 通過,exit 0。
這三個通過代表反例能被重現,不是接線成功。合成封套也不是 Paperclip 的 PRP encoder。這點要分清楚,否則又會拿一份綠色輸出來證明它根本沒測的事。
修正後的方向是:保留原始產物,對同一份 bytes 算雜湊,再驗裝置簽章及 task/attempt/nonce 的綁定。事件封套的 digest 另驗,兩個不要混成一個。
上游文件還有一句影響更大:legacy adapters 不走這個 native writer。因此即使 process adapter 能執行 spectyn,也不能假設它自動得到 native PRP 的所有事件保證。下一次 spike 要驗實際路徑。
我原本把 watchdog 寫成:「eval 評論有 gap_warning,它就不接受 done。」這句太滿。
上游的 task watchdog 是逐 issue 配置的 agent。當整個被監看的子樹停止,而且沒有繼續執行的路徑,系統才喚醒它,讓它讀證據、判斷停止是否合理。它可以做事後審查,卻不會因為我新增一種 JSON 評論,就自動變成那種評論的 parser。
所以現在把兩件事拆開驗收。Spectyn adapter 自己要在回寫完成前執行確定性的檢查;watchdog 則另外配置、另外測它能否發現錯誤並重開工作。
這仍然只能保證 adapter 自己不亂報完成。如果別的 agent 或使用者可以直接改票券狀態,我還得驗上游的權限機制。沒有驗過,就不能說整個票券系統都被這個閘保護。
讀回 fanout.rs 時看到 receipt_verified。如果只看名稱,會以為 coordinator 已經驗過 Ed25519。
實際上,目前完成路徑呼叫 validate_receipt 檢查必要欄位、nonce 等條件;旁邊註解明寫,coordinator 尚無 roster 公鑰,簽章在這裡還沒有驗。receipt_signed 也只是記錄簽章欄位是否為字串。
這不是今天才新增的漏洞,也不是今天已修好的功能;它是第一片與第二片之間的已知缺口。現在選型時把它寫清楚,是為了避免外接控制面之後,把舊欄位直接翻譯成「密碼學驗證成功」。
同樣地,L2 加密信封還沒落地,就不能把未來的保密邊界寫成現在的能力。receipt 能驗的是簽署的宣稱;它不會自己證明沒有外送資料。
把設計修正完之後,開始補接點 A。第一個想法是直接在 coordinator 回報處呼叫現成的 Ed25519 驗簽函式。但一走到公鑰來源,就發現不能那麼快。
worker 的端點可以回傳自己的公鑰。用那把鑰驗它自己交來的 receipt,頂多知道這兩個東西彼此一致,不能知道那把鑰是不是我信任的裝置。攻擊者也可以自己生一把鑰、自己簽一份資料,再把兩樣一起交過來。
所以這次先做一個純函式:呼叫者提供已另外驗證的裝置公鑰、原本派工時保存的身分與任務資訊,以及原始輸出 bytes。函式負責把這些東西對起來,不自己去網路拿一把鑰就信。
這看起來像繞遠路,其實是在把下一步的缺口留在正確位置。EpochManifest 的 enrollment、有效期、撤銷與新鮮度,必須由另一層驗好;今天的函式不能代替它。函式成功,也還不能直接代表整個 task 已完成。
第一種是格式通過:欄位存在、nonce 一樣、狀態看起來完整。第二種是簽章通過:這份資料確實對得上某把公鑰。第三種是任務綁定通過:那台裝置簽的,正好是這一次指派給它的 task、attempt、adapter、nonce、scope 與預期執行位置。
假設有人拿另一份合法 receipt 來。簽章是真的,公鑰也正確,但 task_id 是昨天的任務。如果只做第二種驗證,會把真的簽章用在錯的事情上。測試因此不能只有「改一個字,驗簽失敗」;還要把錯的 task_id 重新簽成合法簽章,證明驗證器仍然會在任務綁定那關拒絕它。
同樣地,簽章只證明裝置簽過宣稱,不證明那個程序真的在那裡執行,更不證明沒有外送資料。這一點要寫在函式說明,也寫進文章,才不會下次又從名字推導出不存在的保證。
另一個小地方是輸出參數。我把它設計成 Option<&[u8]>:None 是沒有拿到產物,Some(b"") 才是有一份長度零的產物。
如果兩種狀態都用空字串表示,下載失敗就可能被誤當成正常空結果。下一步接上儲存或控制面時,這個區分會直接決定失敗是否可見。此函式對空 bytes 的接受只代表雜湊能驗;某個任務是否允許空輸出,仍要由任務的完成政策決定。
原始 bytes 也不能為了方便比對,先 trim 或把換行統一。Windows 風格的 CRLF、Unix 的 LF,以及尾端多一個換行,對畫面可能沒有差別,對被簽的產物卻是不同內容。
rotation 尚未有歷史 manifest 驗證,遇到就回 RotationUnverified。L2 還沒實作,這支函式只接受既有第一片明確標記的 key_recipients_sha256: null 加 key_recipients_reason: "no-envelope"。不能看到某個雜湊字串就猜它算的是明文還是密文。
這些拒絕不是功能已完整的證明。它們讓尚未做好的分支不會偷偷變成成功。下一個人接手時,看到的是可辨識的限制,而不是一個看似通用、其實只懂半套協定的驗證器。
這次先建立測試與一個暫時永遠回傳 Ok(()) 的函式,再跑一次,確認測試會抓到「什麼都放行」。結果是 10 個測試函式中 2 個正常案例通過、8 個拒絕案例失敗,exit 101。
這個 RED 的意思很精確:新測試確實會拒絕不完整的新實作。它不代表從原本產品裡一次找到八個漏洞,因為永遠放行的是這輪刻意建立、尚未接到產品路徑的暫時基線。
補上實作後,跑整個 mesh_crypto 模組,而不只跑新檔。第一輪結果 25 passed、0 failed、exit 0,其中新驗證器有 10 個測試函式,其餘是既有 canonical CBOR 與 receipt 鏈測試。有些函式迴圈檢查多個欄位,所以「10 個」是測試函式數,不是所有輸入情境的總數。
cargo test --locked --offline --lib mesh_crypto::receipt_verifier::tests -- --nocapture
RED: 2 passed; 8 failed; exit 101
cargo test --locked --offline --lib mesh_crypto:: -- --nocapture
GREEN(第一輪): 25 passed; 0 failed; exit 0
我特別保留三個測試名稱,因為它們直接說出這個接點在保護什麼:
missing_trusted_key_cannot_use_self_reported_key:沒有可信公鑰,不能改信對方自報的鑰。valid_signature_does_not_authorize_another_assignment:真的簽章不能替另一個任務過關。original_output_is_required_and_never_normalized:缺產物不算成功,比對時也不能替產物修換行。最後一項很容易被「使用者看起來一樣」掩蓋。畫面可以整理文字,證據比對卻必須用原本被簽的 bytes。這也是我想借用現成控制面,卻還保留這一段自己維護的理由:接點上的資料語義需要自己驗到。
第一輪 review,一位審查者通過,另一位要求修改。回饋裡有兩件值得採納:原本 fixture 都從鏈的第一筆開始,缺第二筆以後的情境;另外,呼叫端把預期值填成空字串時,錯誤訊息不應與 receipt 真的不符混在一起。
於是增加第二筆 receipt 的成功案例,同時確認 seq = 2 卻使用全零前一筆雜湊時會被拒絕。成功案例只證明這份已簽 metadata 的形狀符合規則,沒有取回前一筆,所以仍然不能稱為鏈連續性驗證。
又補了整個欄位被刪除、receipt 不是 object、簽章缺失或使用大寫 hex 等情境。這些與「欄位改成另一個字串」不同:資料被截斷、序列化工具產生不同型別,走的是不同分支。
錯誤分類則改成:呼叫端空值是 InvalidExpectation,receipt 與完整預期值不符才是 BindingMismatch。但「兩邊都空,所以應該通過」沒有採納。兩個空身分一樣,不代表有一個可授權的身分。
修正後,新 helper 有 14 個測試函式,加上既有 15 個,最終回歸 29 passed、0 failed、exit 0。第一輪的 25 個並沒有被抹掉,因為它記錄的是當時那份程式;最後的 29 個則對應補強後的版本。
let bytes = output.ok_or(OutputUnavailable)?;
if digest != crate::rpc_wire::sha256_hex(bytes) {
return Err(OutputMismatch);
}
這是目前實作中的輸出檢查。它沒有替缺少的資料找一個預設值,也沒有先把 bytes 整理成「看起來差不多」。簡單的程式碼,前面卻要先回答哪些資料可信、驗什麼、哪一種錯誤可以繼續。
另一個流程上的發現是,開兩個不同名字的 AI CLI,不一定代表兩個不同 provider。repo 的 wrapper 對 OpenCode 的 routed alias 已有這個提醒。因此最後一輪用 AGY 和 Claude 分開看,OpenCode 的意見仍保留,但不只靠 CLI 名字就宣布不同 provider 的門檻成立。
最後一輪 AGY 與 Claude 都回 APPROVE,沒有 blocker;補強後 cargo check --locked --offline 也再次 exit 0。這兩份審查只涵蓋本次單份驗證器,不代表 EpochManifest、coordinator 或整個產品已通過驗收。原始 log 與 source hash 留在本機開發證據目錄,摘要寫回 m5.md。
| 路徑 | 現在缺的證據 | 下一步 |
|---|---|---|
| 整套替換 | 手機獨立工作、記憶可攜、撤銷、取消、恢復及資料邊界 | 保留比較,不先刪核心 |
| 局部整合 | 一張票與一份產物的端到端完成/失敗/取消 | 優先做有界 spike |
| 借用設計 | 能獨立重現的失敗 fixture | 程序退出三態、shim、配對逐項比 |
這是目前的工程建議,還不是正式採用決議。上游閱讀版本固定在一個 commit,與真正部署時選定的版本也分開記。
開源評估最有價值的地方,是發現自己原本打算省掉的那段工作,其實仍然需要自己負責。
今天完成一份修正過的組合草案、三個離線反例,以及用真 Ed25519 簽章測過的單份 receipt 驗證器。控制面接線與可信公鑰來源仍待完成。每一個成果都有自己的驗收範圍,不能把其中一個綠燈當成整個系統的綠燈。
下一步先用同一資料集比較記憶修正與重啟回想,確認外部元件實際補上哪個產品缺口。可信公鑰與 coordinator 接線仍是遠端派工的必要工作;Paperclip 單票試驗保留為候選,不再預設它是產品入口的下一步。
c65fc9e3c81c41aafe421aa90a00514b84343285。m5.md 的 2026-09-21 開源接點查證節、組合草案 draft-2、離線腳本。重新讀 BIG-GOAL 後,我發現自己先前的順序偏向開發控制面:先驗 receipt,再接 Paperclip 的票券。但原始目的還包含跨工具的私人記憶,以及桌面關機時手機仍能工作。把 Orca、Paperclip、Hermes 接起來,不會自動完成這兩件事。
因此這次提出另一種組法:Spectyn 保留 App、記憶與同意契約,先用一個可替換的執行器驗證日常流程;Paperclip 與 Orca 留作可選的工作入口。這是待驗證建議,沒有直接改成既定架構。Hermes 值得試,但其技能與記憶還要經過匯出、切換引擎與權限檢查;OpenClaw 也值得整套比較,但官方 iOS 文件描述的 Gateway、離線快取與待送佇列,不能當作手機獨立 agent 的證據。
下一個選型實驗應先回答:我糾正一次後,重啟、換一個執行器,它還記得嗎?把外部工具移除後,我的記憶還能讀嗎?這輪留下的是架構比較,沒有新的整合成功宣告,也不把先前 29 個 crypto 測試擴張成產品驗收。
這次把範圍擴到 OpenJarvis、MemOS、mem0、Letta Code,以及手機上的 PocketPal、Maid。真正跑到的是 Mac 上四個局部實驗:OpenJarvis、mem0、MemOS 本機插件與 Hermes。來源固定 commit,使用合成資料,沒有搬私人記憶。
最容易誤判的是:回答正確,不代表記憶修正完成。OpenJarvis 接本機模型,新程序把全部記憶放進測試 prompt 後回答新的 14:00,但舊的 09:00 偏好仍在;這還不是原生檢索測試。mem0 這組設定把修正變成另一筆 ADD,搜尋把新舊一起找出來。這只是此次案例與模型的結果,不能擴大成它們永遠不會修正。
MemOS 與 Hermes 的明確更新 API 能改掉舊內容,重啟與刪除檢查也過了;但程式指定更新和模型自己決定如何更新,難度不同,不能宣布它們的學習能力勝出。Hermes 的本機記憶已更新,同一 session 的 system prompt snapshot 依設計仍是舊版本,下次載入才更新。接線時必須說清楚修正何時生效。
還有「刪掉」的歧義:mem0 搜尋已空,history.db 仍保存原文與刪除事件。這可符合稽核需求,卻不能當成完整遺忘。MemOS 本機插件預設遙測也是必須另外核對的設定。
暫時把 MemOS 列為下一個記憶 adapter 試驗,Hermes 保留作 executor,OpenJarvis 作 Life 對照,PocketPal 作手機推論參考。沒有整套採用決定。iPhone 真機嘗試被鎖定狀態擋住,不能寫成測過。完整 SHA、命令與缺口留在 m5.md。
新增來源:OpenJarvis、MemOS、mem0、PocketPal。
第二批用英文時間、中文飲料、中英切換語言,寫入修正後另開程序回想。先用 qwen2.5-coder:7b 測 MemOS 得到兩例正確;Hermes 卻因需要至少 64K context 而拒絕啟動。因此改用本機已有的 Llama 3.2,獨立設定 65536 context,兩者整批重跑,沒有略過原生限制。
這次三例的新程序回答都未達標。MemOS 的 lightweight pipeline 檢索 packet 只選到舊紀錄;本探針未接後續 memos_search/get 工具,不能當整套產品成績。Hermes 的真實 memory tool loop 則出現新增新值卻保留舊值、工具參數錯誤。這是單次小模型組合測試,不足以判定框架優劣。
我也把 MemOS 的實際歷史文字連同來源、時間匯出,再用自己寫的實驗 adapter 匯入 Hermes。儲存成功,模型卻答 UNKNOWN。移除匯入項後仍是 UNKNOWN,不能把原本就失敗的 recall 當成撤回成功證據;來源和匯出檔也都還在。
因此文章中的「記住了」分三格:資料是否保存、是否正確召回、舊偏好是否確實失效。這一輪補的是可重現失敗與邊界,不增加產品完成度,也沒有做整套替換的決定。完整條件和原始證據見工作樹 MESH-COMPOSITION §14 與 m5 round2;草稿尚未發布。
Letta Code 也補了實際 local CLI 測試:memory-only 設定下,模型把更新指向不存在的記憶檔;修正後的新對話仍回 UNKNOWN。原生匯出通過檔案雜湊核對,但匯出的只是初始模板,不能寫成「記憶退場已完成」。local backend 也不等於全域設定完全不動,測試後已清除自身 agent 登錄。手機雖已連線,鏡像仍要求先鎖定螢幕;PocketPal 真機測試尚未完成,不補造數字。
第二批 review:AGY 對最終文字證據邊界 APPROVE;Claude 初審要求六項方法修正,已補入 §14,但最終複核 exit142 timeout,沒有第二份最終 APPROVE。正式 double-gate 未完成;未採用/提交/發布。原始初審與 final timeout 分別保存。
本篇素材截止於桌面開源候選的第二批實驗與其證據邊界。仍是未發布草稿;收尾不代表未完成測試或審查已通過。後續手機實測與新的開發切片移到下一篇,避免混淆不同批次條件。