Codex CLI가 체인지셋을 GitHub나 GitLab에 올리고 싶을 때 부르는 툴이다. osFoundry MCP 서버는 로그인을 안 한 클라이언트한테도 이렇게 보여 준다.
{"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"]}]} 눈여겨볼 건 없는 쪽이다. 브랜치 이름도, 제목도, 머지 대상 브랜치도, draft 플래그도, 토큰도 없다. 클라이언트가 넘기는 건 체인지셋 하나랑 그걸 읽었을 때 본 사실 두 개뿐이고, 나머지는 전부 오너가 승인하는 순간 오너 쪽에서 채워진다. 이 글에서 뜯어볼 건 "push only its dedicated branch"라는 약속이다. 보통 그 앞에 먼저 불리는 propose_gitnest_file_import도 같이 보고, 200 KB라는 한도가 실제 파일에서 어느 정도인지도 재 봤다.
이 Mac에는 codex 바이너리가 없어서 클라이언트 쪽은 Python 스크립트로 대신했다(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 (위 출력에서 required 목록은 잘랐다. 토큰 수는 cl100k_base 기준이라 Codex 토크나이저랑은 조금 다를 수 있다.) 네 툴을 다 합쳐도 목록 전체 12,662 토큰 중 1,000도 안 된다. Codex 설정의 enabled_tools에 이 넷만 적어 두면 나머지 55개는 프롬프트에 안 들어간다.
왜 공개 툴 둘은 "open world"고 가져오기는 아닐까
openWorldHint는 툴이 닫힌 시스템 밖까지 손을 뻗는지를 클라이언트한테 알려 주는 값이다. 파일 가져오기는 프로젝트 안에서만 보이는 GitNest 저장소에 쓰는 거라 밖으로 안 나간다. 반면 PR은 네이티브든 아니든 다른 사람한테 알림이 가고, 다른 사람이 보고 움직이는 물건이다. Codex는 이런 어노테이션을 보고 언제 멈춰서 물어볼지 정하니까, 서버가 붙인 힌트가 osFoundry 승인 카드보다 먼저 오는 승인 체인의 첫 단계가 된다.
파일 가져오기에는 뭘 실을 수 있을까
거의 다 스키마에 답이 있다. 라이브 inputSchema에 12가지 케이스를 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뿐이다. 모드 비트가 없으니 스크립트는 실행 권한 없이 들어간다. 삭제도 안 된다. 삭제하려면 content 없는 엔트리가 필요한데 content가 필수라서다. 엔트리는 하나하나가 파일 전체 텍스트로 추가하거나 덮어쓰는 거고, 오너가 승인 카드에서 읽는 것도 딱 그 전체 텍스트다.
스키마로 표현 못 하는 한도가 하나 있다. 설명문에 적힌 합계 200 KB다. maxLength는 문자 수를 세는데, 이 한도는 UTF-8 바이트로 센다.
JA file: 100,000 chars passes maxLength; UTF-8 = 300,000 bytes 일본어 파일 하나가 파일당 문자 상한에 딱 맞으면, 그것만으로 커밋 전체 한도의 1.5배다. 스키마만 보는 클라이언트는 서버가 거절할 수밖에 없는 호출을 만들게 된다. 바이트는 클라이언트에서 세 두자.
실제로 가져오면 승인 카드가 몇 장 나올까
Part 3에서는 실제 디렉터리를 20파일, 200 KB 안에 들어가게 크기순 first-fit으로 나눠 담고 제안 수를 셌다. 제안 하나가 오너가 열어 봐야 하는 카드 한 장이다.
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파일 상한에 먼저 걸리고, 큰 컴포넌트나 라틴 문자가 아닌 텍스트는 바이트 한도에 먼저 걸린다. "KB"를 1,000으로 보든 1,024로 보든 이 세 세트에서는 결과가 똑같았다.
하나 더 볼 건 커밋 수다. 제안이 4개면 브랜치에 커밋이 4개 생기고, 하나씩 따로 승인받고, 그중 어느 것이든 오너가 거절할 수 있다. 파일들이 같이 있어야만 의미가 있다면 커밋 메시지마다 그걸 적어 두거나, 아예 카드 한 장에 들어갈 만큼 작게 가져오는 게 낫다.
"push only its dedicated branch"를 git으로 풀면
osFoundry 서버 쪽 프로바이더 push 코드는 공개돼 있지 않다. 대신 데스크톱 앱이 직접 하는 push, 그러니까 GitNest의 Push Change Set Branch 버튼 뒤에 있는 gitnest_desktop.rs의 gitnest_push_change_set_branch는 볼 수 있다. 퍼블리케이션 프리플라이트가 돌려준 값을 받아서 이 순서로 처리한다.
-
origin의 fetch URL과 push URL을 읽고, 둘이 다른 저장소를 가리키거나 프리플라이트가 말한 프로바이더, 오너, 저장소가 아니면 거절한다. - 브랜치가 정확히
osfoundry/change-set-<id>이고<id>가 UUID인지 본다. - 마커가 정확히
GitNest-Change-Set: <id>인지 본다. - 합성 OID가 4~64자 16진수인지 보고,
git cat-file -e <oid>^{commit}으로 실제로 있는지 확인한다. -
git update-ref refs/heads/<branch> <oid> <old>를 돌린다.<old>는 읽었을 때 ref가 가리키던 값이고, 없었으면 빈 값이다. - refspec 하나만 push한다.
git push origin refs/heads/<branch>:refs/heads/<branch>.
스크립트에서는 GitHub 대신 bare 저장소를 하나 세워서 이걸 그대로 재현했다. 클론 쪽에는 오너가 아직 push 안 한 게 두 개 있다. 로컬 브랜치 wip/secret-pricing, 그리고 release/2.4에 올라간 커밋 하나. 합성 커밋은 워크트리 없이 만든다(임시 인덱스, 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을 쓰는 이유는 두 번째 줄에 다 있다. 오너 클론에서 편하게 push했다면 리뷰 안 된 핫픽스랑 비공개 브랜치까지 체인지셋이랑 같이 올라갔을 거다. 명시적 refspec은 ref 하나만 움직이고, 그 이름은 체인지셋 ID에서 나오니까 누구 브랜치랑도 겹칠 일이 없다.
뭐가 거절될까
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" 케이스는 너무 깐깐해 보이지만 그렇지 않다. 심볼릭 이름은 push하는 그 순간 체크아웃돼 있는 걸로 풀리는데, 그게 합성하고 리뷰한 커밋이라는 보장이 없다. 16진수 OID는 커밋 하나만 가리킬 수 있다. 오래된 값으로 update-ref하는 케이스는 git 자체의 compare-and-swap이다. 읽고 쓰는 사이에 로컬 브랜치가 움직였으면 몰래 덮어쓰지 않고 실패한다. URL 체크는 눈에 덜 띄는 구멍을 막는다. git remote set-url --push를 쓰면 push만 fetch와 다른 곳으로 보낼 수 있는데, 코드는 둘이 같은 저장소를 가리켜야만 통과시킨다.
승인할 때 다시 체크하면 뭐가 달라질까
git 단계는 push를 지킨다. expected_ 필드 두 개는 판단을 지킨다. Part 5에서는 툴 설명("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는 버전 3이랑 커밋 73e59f27bc4f…를 읽었고, 오너 카드에도 둘 다 나온다. 그사이 누가 체인지셋을 다시 합성하면, 카드를 승인하는 순간 아무도 안 본 걸 공개하게 되니까 제안이 실패한다. 버전만으로는 부족하다. 같은 버전이라도 합성 결과가 다를 수 있어서다. OID만으로도 부족하다. 오너가 체인지셋의 어느 편집을 뜻했는지는 OID가 말해 주지 않는다. 둘 다 고정하는 데 클라이언트가 내는 비용은 필드 두 개다.
비용은 얼마나 들까
20 runs, local bare remote: compose median 114.2 ms, checks+update-ref+push median 140.0 ms (max 430.0) 체크 6개랑 push를 합쳐도 로컬 bare 리모트 기준 140 ms 정도다. 실제 프로바이더라면 네트워크 왕복이 대부분을 차지한다. 라이브 tools/list 5번이랑 공개 21번까지 포함해서 스크립트 전체가 실제 시간 11.7초, 유저 CPU 3.5초 걸렸다.
직접 만든다면
- 에이전트한테는 체인지셋이랑 본 것(버전과 커밋)만 넘기게 하고, 브랜치, 제목, 대상, 토큰은 절대 받지 말자. 브랜치 이름은 체인지셋 ID에서 만든다.
- push는 명시적 refspec 하나로. 사람도 쓰는 클론에서
--all,--mirror, 인자 없는git push는 금지다. - 로컬 ref는 기대하는 이전 값과 함께 쓰고, fetch와 push URL이 같은 저장소인지 확인한다.
- 공개될 대상은 16진수 OID만 받는다. 심볼릭 이름은 리뷰 뒤가 아니라 리뷰 전에 풀어 둔다.
- 가져오기 한도는 바이트로 적고 클라이언트에서 확인한다. 스키마의 문자 수 제한으로는 라틴 문자가 아닌 텍스트를 못 잡는다.
- 다른 사람한테 닿는 툴에는
openWorldHint: true를 붙여서, 내 승인보다 클라이언트 쪽 승인 프롬프트가 먼저 뜨게 하자.
저장소 안에서 체인지셋, 체크, 공개가 어떻게 이어지는지는 GitNest 페이지에, 프로토콜 쪽 이야기는 MCP 용어 설명에 정리돼 있다.