Claude 對話想透過 osFoundry 的 MCP 連接器把檔案放進 GitNest repository 時,用的就是下面這個工具。內容取自正式環境 endpoint 公開的 tools/list,2026-11-06 用 curl 抓取(讀取工具目錄不需要 token):
$ curl -s -X POST https://api.osfoundry.io/api/v1/external-mcp \ -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' {"name": "propose_gitnest_file_import", "description": "Prepare up to 20 UTF-8 files (200 KB total) for a native GitNest repository branch. The owner reviews every file and approves the commit in osFoundry. Repository GitNest must be enabled.", "inputSchema": {"required": ["repository_id", "branch", "message", "files", "idempotency_key"], "properties": { "files": {"minItems": 1, "maxItems": 20, "items": {"properties": {"path": {"maxLength": 2048}, "content": {"maxLength": 100000}}}}, "idempotency_key": {"type": "string", "minLength": 8, "maxLength": 128}}}, "annotations": {"readOnlyHint": false, "destructiveHint": true, "openWorldHint": false}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]} 注意動詞。工具負責「prepares」,由 owner 來「approves the commit」。呼叫在任何東西碰到 Git 之前就回傳了,而連接器上也沒有其他工具會碰到 Git。idempotency_key 是必填而不是選填,還有最小長度限制,所以 client 不能送一個空字串或單一字元的 key,然後聲稱自己是 idempotent。
本文接下來會說明為什麼這種設計對 agent 來說是對的,以及伺服器必須做哪些事才能讓它名副其實。接著在一個真實的 Git repository 上重建最小版本,並實際量測拿掉某些部分時會出什麼問題。
七個 propose 工具各自鎖定了什麼?
GitNest 在連接器上有 13 個工具。其中六個只做讀取:read_gitnest_repository、search_gitnest_code、list_gitnest_work、read_gitnest_work、list_gitnest_saved_results 和 list_gitnest_checks。另外七個都以 propose_ 開頭。從它們的必填欄位,可以看出每個工具在模型呼叫的當下把什麼固定下來:
| 工具 | idempotency_key | expected_change_set_version | expected_composition_oid | openWorldHint |
|---|---|---|---|---|
propose_gitnest_work | 必填 | 無 | 無 | false |
propose_gitnest_change_set | 必填 | 無 | 無 | false |
propose_gitnest_server_composition | 必填 | 必填 | 無 | false |
propose_gitnest_file_import | 必填 | 無 | 無 | false |
propose_gitnest_check_rerun | 必填 | 必填 | 必填 | false |
propose_gitnest_native_publication | 必填 | 必填 | 必填 | true |
propose_gitnest_provider_publication | 必填 | 必填 | 必填 | true |
影響越大,鎖定的東西越多。task 只鎖定自己的 key。compose 會鎖定模型讀到的 change set 版本。重跑 check 或發布 pull request,則同時鎖定版本和確切的 composed commit。兩個 publication 工具都標記為 openWorldHint: true,因為它們會在 proposal store 之外產生東西:一個 pull request,而 provider 工具還會把一個 branch push 到 GitHub 或 GitLab。
在 owner 的審查卡片上,這些鎖定值會變成文字:"Change set: … · expected version 3"、"Reviewed commit: 9c1e47a…"。owner 核准的是那個版本和那個 commit,而不是按下按鈕那一刻剛好最新的東西。
為什麼不直接給 agent 一個 push token?
因為 push 在模型送出的那一刻就決定了,而 proposal 是在一個擁有當下權限的人看過之後才決定。
osFoundry 的程式碼明確劃出這條界線。GitNest 的寫入 route 由 denyAgentScope 把關,而 app 內通往 GitNest 的 Maestro bridge 只允許讀取。Connected tools 也沒有借用瀏覽器 token 來繞過這一點。每個 proposal 都會被儲存、顯示給連線的 owner,核准後由 osFoundry 自己的 writer 執行,並記在該連線名下。Claude 對話從頭到尾都不會拿到能寫入 repository 的憑證,也看不到 owner 的 GitHub 或 GitLab token。provider publication 只會 push 該 change set 專用的 branch,搭配 absent-ref lease,絕不會鏡像無關的 ref。
這個選擇的代價是時間。從「proposed」到「approved」之間的空檔是人的時間:幾分鐘,有時一整天。這段期間 branch 會前進、有人重新 compose change set、某個 check 跑完、owner 失去 repository 角色、連線被撤銷。osFoundry 的核准流程會重新檢查連線、專案成員資格、owner 目前的 repository 角色、有效期限;change set 相關動作還會檢查確切的版本和 commit,publication 則再加上風險政策和必要的 check。"A declined, expired, or stale proposal must not execute" 就是它計畫中的一行。
剩下兩種失敗模式,也就是重建要處理的主題:
- 兩個核准針對同一個 base。各自看起來都沒問題。
- 部分失敗後的重試。Git 已經有了 commit,資料庫還不知道,owner 又按了一次。
針對第二種,osFoundry 的計畫描述了已在 Git service 上線的修正:file import 會把不可變的 proposal ID 當作 idempotency key 傳入,service 則 "updates the branch and replay marker atomically with compare-and-swap, so a database failure after the Git commit can be retried without another commit."
重建版本
兩個檔案,只用標準函式庫加上 git CLI(Apple Git 2.39.5),在一台同時還在替其他工作 render 影片的 Apple M1 Mac 上執行:
-
server.py,225 行:架在 bare Git repository 和一個 sqlite 檔案上的 JSON-RPC endpoint。 -
client.py,203 行:一個寫成腳本的「Claude」和一個寫成腳本的 owner。
過程中沒有用到任何 Claude 執行檔或 Claude 帳號。client 送出的 tools/call 請求,和已連線的對話會送出的是同一類。重建版本的工具是 read_repo、propose_file_import 和 read_proposal。這些名稱是重建版本自己取的,而且沒有一個會寫入 Git。owner 透過另一個 route /owner/decide 做決定,這個 route 遇到 agent token 會回應 403 agent_scope_denied。這就是用一個 if 實現的 denyAgentScope 概念。
有一個刻意的差異:重建版本在 file import 時也會鎖定 base commit(expected_base_oid,預設是建立 proposal 當下的 branch tip)。osFoundry 的 propose_gitnest_file_import schema 沒有這個欄位。請把這部分當作重建版本的設計選擇,而不是 osFoundry 的描述。
proposal 的接收在 (connection, idempotency_key) 上是 idempotent 的,並保存 body 的 hash,所以同一個 key 搭配不同 body 會變成錯誤,而不是默默地 replay:
c.execute("INSERT OR IGNORE INTO proposals (id,conn,idem_key,body_hash,branch,base_oid,files," "message,status,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,'pending',?,?)", (...)) row = c.execute("SELECT * FROM proposals WHERE conn=? AND idem_key=?", (conn, key)).fetchone() if row["body_hash"] != h: raise Conflict("idempotency_key_reused_with_different_body") 核准時只用 plumbing 指令建立 commit(read-tree 到暫存 index、hash-object -w、update-index --cacheinfo、write-tree、commit-tree -p <base>)。這些都不會移動任何 ref。接著用一個 transaction,只在 branch 仍指向審查過的 base 時才移動它,同時建立一個以 proposal 命名的 marker ref:
tx = (f"start\nupdate refs/heads/{branch} {new} {base_oid}\n" f"create refs/proposals/{proposal_id} {new}\nprepare\ncommit\n") git("update-ref", "--stdin", inp=tx) 帶舊值的 update 就是 compare-and-swap。ref 已存在時 create 會失敗。Git 要嘛兩個都套用,要嘛都不套用。在建立任何東西之前,核准流程會先找 refs/proposals/<id>。如果它存在,代表先前的嘗試已經寫進 Git,於是記錄那個 commit 並當作 replay 回傳。
腳本化的 Claude 看到了什麼
python3 client.py scenario,proposal UUID 已縮短。過期那一行來自另一台以 0.3 s TTL 啟動的伺服器:
read_repo -> head 29a940e8 (1 commit) propose A README.md key=readme-fix-0001 -> {"proposal_id": "96dcf05f-…", "status": "pending", "base_oid": "29a940e8"} propose A (retry, same key+body) -> {"proposal_id": "96dcf05f-…", "status": "pending", "base_oid": "29a940e8", "replayed": true} propose A' (same key, other body) -> idempotency_key_reused_with_different_body propose B app.py key=app-bump-0001 -> {"proposal_id": "c91824e3-…", "status": "pending", "base_oid": "29a940e8"} decide A with the agent token -> HTTP 403 {'error': 'agent_scope_denied'} owner approves A -> HTTP 200 {"status": "executed", "commit": "f07b9fda"} (2 commits) owner approves B -> HTTP 200 {"status": "stale", "refused": true, "base_oid": "29a940e8", "branch_head": "f07b9fda"} (2 commits) propose B2 on new head f07b9fda -> {"proposal_id": "bc3cedfc-…", "status": "pending", "base_oid": "f07b9fda"} owner approves B2, crash before DB -> HTTP 500 {'error': 'injected: crash after git, before DB'} (3 commits) owner retries approval of B2 -> HTTP 200 {"status": "executed", "commit": "fb736eda", "replayed": true} (3 commits) owner clicks approve on A again -> HTTP 200 {"status": "executed", "commit": "f07b9fda", "replayed": true} (3 commits) approve after decline -> HTTP 200 {'status': 'declined', 'refused': True} (3 commits) approve after expiry (ttl 0.3 s) -> HTTP 200 {'status': 'expired', 'refused': True} (1 commit) fb736ed Claude (conn-claude-research) | repo-owner | Bump app f07b9fd Claude (conn-claude-research) | repo-owner | Pin install version 29a940e owner | owner | seed B 改的是 app.py,A 改的是 README.md,B 還是被拒絕了。這是對「owner 是基於 29a940e8 審查的」最嚴格的解讀。修正成本很低:client 讀取新的 head 再提案一次,B2 就是這樣來的。git log 那幾行顯示了 store 寫入的來源資訊。author 是連線,committer 是按下核准的人,訊息裡帶有 Proposal: <id> 和 Approved-by: trailer。
沒有 compare-and-swap 會壞掉什麼?
--mode naive 除了 transaction 以外全部保留。它在 Python 裡檢查 tip、建立 commit,然後不帶舊值也不建 marker,直接執行 git update-ref refs/heads/main <new>。同一個伺服器、同一個 client、同樣的 repository 配置。
競態測試:兩個不同的 proposal 基於同一個 base,從兩個 thread 在同一瞬間核准(threading.Barrier),每輪 100 次,跑兩輪。每次結束後,client 用 git merge-base --is-ancestor 確認每個回報為 executed 的 commit 是否真的在 main 上。
當機測試:在 Git 步驟之後、資料庫更新之前注入一次失敗再核准,然後再核准一次。每輪 50 次,跑兩輪。client 計算前後 main 上的 commit 數。
| 模式 | 競態:兩者都回報「executed」 | 競態:已核准但不在 main 上的 commit | 當機後重試 | 重試後多出的 commit |
|---|---|---|---|---|
| CAS + marker, run 1 | 0 / 100 | 0 | 50 × executed (replayed) | 0 |
| CAS + marker, run 2 | 0 / 100 | 0 | 50 × executed (replayed) | 0 |
| naive, run 1 | 100 / 100 | 100 | 50 × stale | 0 |
| naive, run 2 | 100 / 100 | 100 | 50 × stale | 0 |
naive 的競態測試每一次都失敗,不是偶爾。從「tip 等於 base」到「寫入 ref」之間的空窗有四個 git process 那麼長,幾十毫秒,被 barrier 同時放行的兩個 thread 每次都會一起落在裡面。兩個核准都回傳 executed 和一個 commit ID。owner 看到兩個綠色結果,但其中一個 commit 根本不在 branch 的歷史裡。完全沒有錯誤。有了 compare-and-swap,每一次都剛好是一個 proposal executed,另一個收到 stale 拒絕,並附上新的 head。
naive 模式的當機重試沒有重複 commit。base 檢查擋住了它,因為 branch 已經前進了。但它是以 stale 的形式被擋下的。這個 proposal 自己的 commit 明明在 main 上,store 卻回報它被拒絕。如果為了消除這個問題而拿掉 base 檢查,重試就會把 commit 再寫一次。正是 replay marker,讓重試能說出「這件事已經做過了,這是那個 commit」。
代價是多少?
從 client 經 loopback HTTP 量測每個請求的實際耗時(urllib,每次都建立新連線),每種操作每輪 n = 200,跑兩輪。範圍為 run 1 到 run 2。
| 操作 | CAS median | CAS p95 | naive median | naive p95 |
|---|---|---|---|---|
| propose(新 key) | 4.42–4.44 ms | 7.1–12.3 ms | 4.08–4.66 ms | 6.4–10.0 ms |
| propose(同 key,replay) | 1.42–1.50 ms | 3.3–4.5 ms | 1.27–1.49 ms | 2.4–4.1 ms |
| approve,執行 | 115–119 ms | 199–257 ms | 92–105 ms | 130–193 ms |
| approve,以 stale 拒絕 | 28–31 ms | 52–70 ms | 14–17 ms | 24–34 ms |
| 再次 approve,已執行過 | 1.46–1.60 ms | 3.4–4.7 ms | 1.38–1.54 ms | 2.6–4.2 ms |
時間幾乎都花在啟動 process 上。一次執行成功的 CAS 核准會跑八個 git process(marker 查詢、tip 查詢、read-tree、hash-object、update-index、write-tree、commit-tree、update-ref --stdin),naive 則是六個。stale 拒絕是兩次查詢對一次。在這台負載偏高的機器上,每個 git process 大約 14 ms,所以安全路徑大約多兩次 spawn,約 15–25 ms,而這個請求本來就在等人按按鈕。改用 libgit2 而不是呼叫 shell 的伺服器,可以把這些欄位全部壓低。兩者之間的差距仍會是那兩次查詢。
replay 在兩張表裡都是最便宜的路徑,只要一次 sqlite 讀取。會重試的 client,對伺服器來說比新請求還便宜。
如果你要自己實作
- 不要給 agent 任何能寫入 repository 的工具,只給
propose_*,並在 decide route 上檢查呼叫者不是 agent token。不要指望 agent 找不到那個 route。 - 把審查者看到的東西(版本和 commit)鎖定在 proposal 裡,核准時拿來比對。拒絕時在 response 裡附上目前的 head,讓 client 可以重新讀取再提案。
- 讓 Git 寫入成為單一的 ref transaction:帶舊值的 branch 更新,加上一個以 proposal 命名、只能建立的 marker。建立任何東西之前先找 marker。
- 要求 idempotency key 並設定最小長度,連同 body 的 hash 一起儲存,同一個 key 搭配不同 body 時直接拒絕。
- 把來源資訊寫進 commit 本身。author 是連線,committer 是核准者,再用一個 trailer 帶上 proposal ID。就算資料庫不見了,
git log裡還是會有。 - 停在 pull request。merge 和部署屬於 repository 的擁有者,在他們自己的畫面上進行。
GitNest 本身的部分,包括 clone、push 和 access token,在 osFoundry 的 GitNest 頁面 有說明。這個連接器使用標準的 MCP,所以同樣的 proposal 工具也能在 Claude Code 或任何其他 MCP host 上使用。