這是 Codex CLI 想把 change set 送上 GitHub 或 GitLab 時會呼叫的工具。不論 client 有沒有登入,osFoundry 的 MCP server 列出來的都是這樣:

{"name": "propose_gitnest_provider_publication", "description": "Ask the repository owner to review a composed GitNest change set, push only its dedicated branch using their linked Git provider account, and open a draft pull request. Approval rechecks permissions, risk policy, required checks, and target commit.", "inputSchema": {"properties": { "repository_id": {"type": "string"}, "change_set_id": {"type": "string"}, "expected_change_set_version": {"type": "integer", "minimum": 1}, "expected_composition_oid": {"type": "string"}, "idempotency_key": {"type": "string", "minLength": 8, "maxLength": 128}}, "required": ["repository_id", "change_set_id", "expected_change_set_version", "expected_composition_oid", "idempotency_key"], "additionalProperties": false}, "annotations": {"readOnlyHint": false, "openWorldHint": true, "destructiveHint": true}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]} 

重點在於它少了什麼。沒有 branch 名稱、沒有標題、沒有目標 branch、沒有 draft flag,也沒有 token。Client 只指定一個 change set,外加它觀察到的兩項事實,其餘全部由 owner 那一端在核准時決定。"Push only its dedicated branch" 就是這篇文章要拆開來看的承諾。文章也會談到通常先被呼叫的工具 propose_gitnest_file_import,以及它 200 KB 的額度放到真實檔案上代表什麼。

這台 Mac 上沒有安裝 codex binary,所以 client 端是一支 Python script,就在這裡執行(Apple M1、透過 uv 跑 Python 3.12、git 2.39.5)。網路數據是從日本量測的。

$ uv run --with tiktoken --with jsonschema python3 publish_sim.py == Part 1: live tools/list (no token) == tools/list x5 median 505.7 ms; 59 tools, 13 GitNest read_gitnest_repository osfoundry:read ro=True openWorld=False destructive=False propose_gitnest_file_import osfoundry:write ro=False openWorld=False destructive=True propose_gitnest_native_publication osfoundry:write ro=False openWorld=True destructive=True propose_gitnest_provider_publication osfoundry:write ro=False openWorld=True destructive=True these four = 989 of 12662 tokens 

(上面的輸出已經刪掉必填欄位清單。Token 是用 cl100k_base 算的,對 Codex 自己的 tokenizer 來說只是近似值。)這四個工具只佔清單 12,662 個 token 裡不到 1,000 個。在 Codex 設定的 enabled_tools 裡只寫這四個,其他 55 個就不會進到 prompt。

為什麼兩個 publication 工具都是 "open world",import 卻不是?

openWorldHint 告訴 client 這個工具會不會碰到封閉系統以外的地方。File import 寫入的是只有該專案看得到的 GitNest repository。Pull request 就算是 native 的,也會通知到其他人,其他人也會據此行動。Codex 會依照這類 annotation 決定什麼時候該停下來詢問,所以 server 給的 hint 是核准流程的第一道關卡,比 osFoundry 自己的卡片還早。

File import 能帶哪些東西?

大部分 schema 就已經回答了。對實際的 inputSchema 測了十二個案例,用 jsonschema(Draft 2020-12)驗證:

file_import 20 files ok file_import 21 files REJECT maxItems file_import one file 100,000 chars ok file_import one file 100,001 chars REJECT maxLength file_import file with mode '100755' REJECT additionalProperties file_import delete: {'path','delete':true} REJECT required file_import idempotency_key 7 chars REJECT minLength native_publication full pin ok native_publication no expected_composition_oid REJECT required provider_publication expected_change_set_version 0 REJECT minimum provider_publication extra 'draft': false REJECT additionalProperties provider_publication extra 'target_branch' REJECT additionalProperties 

一筆檔案項目就是 path 加上 content,沒有別的。沒有 mode bits,所以 script 進去後不會是可執行的。也沒有刪除,因為刪除需要一筆沒有內容的項目。每一筆項目都是用完整內容新增或取代一個檔案,而這也正是 owner 在核准卡片上看到的東西。

有一個限制是 schema 表達不出來的:description 裡寫的總計 200 KB。maxLength 算的是字元數,額度算的卻是 UTF-8 bytes:

JA file: 100,000 chars passes maxLength; UTF-8 = 300,000 bytes 

一個塞到單檔字元上限的日文檔案,就已經是整個 commit 額度的一倍半。只檢查 schema 的 client,會組出 server 只能拒絕的呼叫。請在 client 端算 bytes。

一次真實的 import 會變成幾張核准卡片?

Script 的 Part 3 把真實的目錄依大小用 first-fit 切成每次少於 20 個檔案、200 KB 的 import 呼叫,然後計算 proposal 數量。每個 proposal 都是 owner 必須打開的一張卡片:

GitNest web lib (.ts) 25 files 126,756 chars 127,210 bytes (1.00 B/char) | 2 proposals per proposal (files, KB): 20/115, 5/12 GitNest web components (.tsx) 22 files 274,206 chars 274,819 bytes (1.00 B/char) | 2 proposals per proposal (files, KB): 10/200, 12/75 Team 2 JA articles (.md) 55 files 359,213 chars 662,285 bytes (1.84 B/char) | 4 proposals per proposal (files, KB): 13/200, 16/196, 18/194, 8/73 

這些語料是 osFoundry 自己的前端原始碼和這個部落格的日文草稿,直接從磁碟讀取。小型原始碼檔案會先撞到 20 個檔案的上限。大型元件和非拉丁文字則會先撞到 byte 額度。把 "KB" 當成 1,000 或 1,024 bytes,對這三組資料都沒有差別。

另一個要注意的是 commit 數量。四個 proposal 就是 branch 上的四個 commit,要一個一個核准,而且 owner 可以拒絕其中任何一個。如果這些檔案要放在一起才有意義,就在每個 commit message 裡寫清楚,或是把 import 控制在一張卡片就裝得下的大小。

在 git 裡,"push only its dedicated branch" 是什麼意思?

osFoundry 在 server 端對 provider 做的 push 沒有公開。桌面 app 自己的 push 則是公開的,也就是 GitNest 的 Push Change Set Branch 按鈕所用的 gitnest_desktop.rs 裡的 gitnest_push_change_set_branch。它接收 publication preflight 回傳的值,依序做這些事:

  1. 讀取 origin 的 fetch URL 和 push URL,如果兩者指向不同的 repository,或沒有指向 preflight 指定的 provider、owner 和 repo,就拒絕。
  2. 要求 branch 必須剛好是 osfoundry/change-set-<id>,其中 <id> 是 UUID。
  3. 要求 marker 必須剛好是 GitNest-Change-Set: <id>。
  4. 要求 composition OID 必須是 4 到 64 個 hex 字元,接著執行 git cat-file -e <oid>^{commit}。
  5. 執行 git update-ref refs/heads/<branch> <oid> <old>,其中 <old> 是讀取時 ref 的值,如果 ref 原本不存在就是空的。
  6. 只 push 一個 refspec:git push origin refs/heads/<branch>:refs/heads/<branch>。

Script 用一個代替 GitHub 的 bare repository 重現了這段流程。Clone 裡有兩樣 owner 還沒 push 的東西:本地 branch wip/secret-pricing,以及 release/2.4 上多出來的一個 commit。組好的 commit 是在沒有 worktree 的情況下建出來的(用一個拋棄式 index、hash-object、update-index --cacheinfo、write-tree、commit-tree):

remote refs before: 3 (dana/refactor-cart, main, release/2.4) `git push --all` would change 2 remote refs: ['release/2.4', 'wip/secret-pricing'] explicit refspec push changed 1 ref: osfoundry/change-set-5b0c2e8e-3f6a-4d2b-9a71-0c4e9d1f7a23 -> 73e59f27bc4f untouched: True; marker in pushed commit: True 

第二行就是明確指定 refspec 的全部理由。如果從 owner 的 clone 隨手 push,就會連同 change set 一起把未經 review 的 hotfix 和私人 branch 發布出去。明確的 refspec 只會移動一個 ref,它的名稱由 change set ID 推導出來,所以不可能和任何人的 branch 撞名。

哪些情況會被拒絕?

branch name 'codex/ci' instead of contract refused: publication branch does not match the preflight contract marker for another change set refused: publication marker does not match the preflight contract OID 'HEAD' instead of hex refused: composition OID must be a hex commit id hex OID that is not in the clone refused: fatal: Not a valid object name deadbeefdeadbeefdeadbeefdeadbeefdeadbeef^{commit} update-ref with a stale expected old value exit 128: fatal: update_ref failed for ref 'refs/heads/osfoundry/change-set-5b0c2e8e-3f6a- push URL differs from fetch URL refused: origin fetch and push remotes point at different repositories 

"HEAD" 這個案例看起來有點吹毛求疵,其實不然。Symbolic name 會解析成 push 當下 checkout 的東西,而那不一定是當初組好並 review 過的內容。Hex OID 只可能代表一個 commit。過期的 update-ref 是 git 本身的 compare-and-swap:如果本地 branch 在讀和寫之間動過,寫入就會失敗,而不是默默把它蓋掉。URL 分離的檢查則補上一個比較隱蔽的漏洞:git remote set-url --push 可以讓 push 送到和 fetch 來源不同的地方,而程式碼要求兩者必須指向同一個 repository。

核准時的重新檢查多了什麼保護?

Git 的步驟保護的是 push。兩個 expected_ 欄位保護的是決定本身。Script 的 Part 5 根據工具的 description("Approval rechecks permissions, risk policy, required checks, and target commit")重建了這個重新檢查:

as proposed publish recomposed to v4 before the click refused: change set is v4, card says v3 lint re-ran and failed refused: required checks not passed: ['lint'] 

Codex 讀到的是 version 3 和 commit 73e59f27bc4f…,owner 的卡片上也會顯示這兩項。如果有人在這段期間重新 compose 了 change set,核准卡片就會發布一個沒有人看過的東西,所以 proposal 會失敗。只看 version 不夠,因為同一個 version 的兩次 composition 可能不一樣。只看 OID 也不夠,因為它沒辦法說明 owner 指的是 change set 的哪一次編輯。兩者都釘住,client 只要多付兩個欄位的成本。

成本是多少?

20 runs, local bare remote: compose median 114.2 ms, checks+update-ref+push median 140.0 ms (max 430.0) 

六項檢查加上 push,在本地 bare remote 上大約多花 140 ms。換成真正的 provider,主要時間會花在網路來回上。整支 script,包含 5 次實際的 tools/list 呼叫和 21 次 publication,總共花了 11.7 s 的 wall-clock 時間和 3.5 s 的 user CPU。

如果要自己串接

  • 讓 agent 指定 change set 和它觀察到的內容(version 和 commit),絕對不要讓它指定 branch、標題、目標或 token。Branch 名稱從 change set ID 推導。
  • 只用一個明確的 refspec push。在有人也在用的 clone 上,絕對不要用 --all、--mirror 或不帶參數的 git push。
  • 寫入本地 ref 時帶上預期的舊值,並要求 fetch 和 push URL 指向同一個 repository。
  • 要發布的東西一律只接受 hex OID。Symbolic name 要在 review 之前解析,而不是之後。
  • Import 額度用 bytes 表示,並在 client 端檢查。Schema 裡的字元上限擋不住非拉丁文字。
  • 會觸及其他人的工具要標上 openWorldHint: true,讓 client 自己的核准提示比你的更早跳出來。

Change set、checks 和 publication 在 repository 裡怎麼搭配,可以參考 GitNest 頁面;協定的部分則在 MCP 詞彙條目。