Voici l'outil que Codex CLI appelle quand il veut envoyer un change set sur GitHub ou GitLab, tel que le serveur MCP d'osFoundry le liste à n'importe quel client, connecté ou non :

{"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"]}]} 

Regardez surtout ce qui manque. Pas de nom de branche, pas de titre, pas de branche cible, pas de flag draft, pas de token. Le client désigne un change set et deux faits qu'il a observés à son sujet, et tout le reste vient du côté du propriétaire, au moment où il approuve. "Push only its dedicated branch", c'est la promesse que ce post décortique. Il couvre aussi l'outil qui passe généralement en premier, propose_gitnest_file_import, et ce que son budget de 200 KB implique pour de vrais fichiers.

Aucun binaire codex n'est installé sur ce Mac, donc le côté client est un script Python, exécuté ici (Apple M1, Python 3.12 via uv, git 2.39.5). Les mesures réseau ont été faites depuis le Japon.

$ 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 

(Les listes de champs obligatoires ont été coupées dans la sortie ci-dessus. Les tokens viennent de cl100k_base, ce n'est donc qu'une approximation pour le tokenizer propre à Codex.) Les quatre outils coûtent moins de 1,000 tokens sur les 12,662 de la liste. Une config Codex qui les nomme dans enabled_tools garde les 55 autres hors du prompt.

Pourquoi les deux outils de publication sont-ils "open world" et pas l'import ?

openWorldHint indique au client si un outil va au-delà d'un système fermé. L'import de fichiers écrit dans un dépôt GitNest que seul le projet voit. Une pull request, même native, est quelque chose dont d'autres personnes sont notifiées et sur quoi elles agissent. Codex s'appuie sur ce genre d'annotations pour décider quand s'arrêter et demander, donc les hints du serveur sont le premier maillon de la chaîne d'approbation, avant même la carte d'osFoundry.

Que peut contenir un import de fichiers ?

Le schéma répond à presque tout. Douze cas testés contre les inputSchema réels, validés avec 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 

Une entrée de fichier, c'est path plus content, et rien d'autre. Pas de bits de mode, donc un script arrive sans droit d'exécution. Pas de suppression, puisqu'une suppression demanderait une entrée sans contenu. Chaque entrée ajoute ou remplace un fichier avec son texte complet, et c'est exactement ce que le propriétaire lit sur la carte d'approbation.

Il y a une limite que le schéma ne sait pas exprimer : les 200 KB au total indiqués dans la description. maxLength compte des caractères. Le budget, lui, compte des octets UTF-8 :

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

Un fichier japonais au plafond de caractères par fichier pèse déjà une fois et demie le budget du commit entier. Un client qui ne vérifie que le schéma va construire des appels que le serveur devra refuser. Comptez les octets côté client.

Combien de cartes d'approbation donne un vrai import ?

La partie 3 du script répartit de vrais répertoires en appels d'import de moins de 20 fichiers et 200 KB, en first-fit par taille, puis compte les propositions. Chaque proposition est une carte que le propriétaire doit ouvrir :

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 

Les corpus sont les sources frontend d'osFoundry et les brouillons japonais de ce blog, lus directement sur le disque. Les petits fichiers source butent d'abord sur la limite de 20 fichiers. Les gros composants et le texte non latin butent d'abord sur le budget en octets. Lire "KB" comme 1,000 ou 1,024 octets n'a rien changé pour ces trois ensembles.

L'autre point à noter, c'est le nombre de commits. Quatre propositions, ce sont quatre commits sur la branche, approuvés un par un, et le propriétaire peut refuser n'importe lequel. Si les fichiers n'ont de sens qu'ensemble, dites-le dans chaque message de commit, ou gardez l'import assez petit pour tenir sur une seule carte.

Que veut dire "push only its dedicated branch" côté git ?

Le push vers le provider qu'osFoundry fait côté serveur n'est pas public. Celui de l'app desktop, utilisé par le bouton Push Change Set Branch de GitNest, l'est : gitnest_push_change_set_branch dans gitnest_desktop.rs. Il prend les valeurs renvoyées par un preflight de publication et fait ceci, dans l'ordre :

  1. Lit l'URL de fetch et l'URL de push d'origin et refuse si elles pointent vers des dépôts différents, ou pas vers le provider, l'owner et le repo indiqués par le preflight.
  2. Exige que la branche soit exactement osfoundry/change-set-<id>, avec <id> un UUID.
  3. Exige que le marqueur soit exactement GitNest-Change-Set: <id>.
  4. Exige que l'OID de composition fasse de 4 à 64 caractères hexadécimaux, puis lance git cat-file -e <oid>^{commit}.
  5. Lance git update-ref refs/heads/<branch> <oid> <old>, où <old> est ce que contenait la ref au moment de la lecture, ou vide si elle n'existait pas.
  6. Pousse une seule refspec : git push origin refs/heads/<branch>:refs/heads/<branch>.

Le script reconstruit tout ça contre un dépôt bare qui joue le rôle de GitHub. Le clone contient deux choses que le propriétaire n'a pas poussées : une branche locale wip/secret-pricing et un commit en plus sur release/2.4. Le commit composé est construit sans worktree (un index jetable, 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 

Cette deuxième ligne est toute la raison d'être de la refspec explicite. Un push pratique depuis le clone du propriétaire aurait publié, en même temps que le change set, un hotfix non relu et une branche privée. La refspec explicite ne déplace qu'une ref, dont le nom est dérivé de l'ID du change set, donc elle ne peut entrer en collision avec la branche de personne.

Qu'est-ce qui est refusé ?

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 

Le cas "HEAD" a l'air pinailleur, mais il ne l'est pas. Un nom symbolique se résout vers ce qui est checkout au moment du push, ce qui n'est pas forcément ce qui a été composé et relu. Un OID hexadécimal ne peut désigner qu'un seul commit. Le update-ref avec une ancienne valeur périmée, c'est le compare-and-swap de git lui-même : si la branche locale a bougé entre la lecture et l'écriture, l'écriture échoue au lieu de l'écraser en silence. La vérification des URLs séparées bouche un trou plus discret : git remote set-url --push peut envoyer les push ailleurs que là d'où viennent les fetch, et le code exige que les deux désignent le même dépôt.

Qu'apporte la revérification à l'approbation ?

Les étapes git protègent le push. Les deux champs expected_ protègent la décision. La partie 5 du script reconstruit la revérification à partir des descriptions des outils ("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 a lu la version 3 et le commit 73e59f27bc4f…, et la carte du propriétaire affiche les deux. Si quelqu'un recompose le change set entre-temps, approuver la carte publierait quelque chose que personne n'a regardé, donc la proposition échoue. La version seule ne suffit pas, car deux compositions de la même version peuvent différer. L'OID seul ne suffit pas non plus, car il ne dit rien de l'édition du change set que le propriétaire avait en tête. Épingler les deux coûte deux champs au client.

Combien ça coûte ?

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

Les six vérifications et le push ajoutent environ 140 ms avec un remote bare local. Face à un vrai provider, c'est l'aller-retour réseau qui domine. Le script complet, 5 appels réels à tools/list et 21 publications compris, a pris 11.7 s en temps réel et 3.5 s de CPU utilisateur.

Si vous le câblez vous-même

  • Laissez l'agent nommer le change set et ce qu'il a observé (version et commit), jamais la branche, le titre, la cible ou le token. Dérivez le nom de branche de l'ID du change set.
  • Poussez avec une seule refspec explicite. Jamais --all, --mirror ou un git push tout court depuis un clone qu'une personne utilise aussi.
  • Écrivez la ref locale avec une ancienne valeur attendue, et exigez que les URLs de fetch et de push désignent le même dépôt.
  • N'acceptez que des OIDs hexadécimaux pour tout ce qui sera publié. Résolvez les noms symboliques avant la revue, pas après.
  • Exprimez le budget d'import en octets et vérifiez-le côté client. Les limites en caractères du schéma ne rattrapent pas le texte non latin.
  • Marquez avec openWorldHint: true les outils qui atteignent d'autres personnes, pour que la demande d'approbation du client se déclenche avant la vôtre.

La façon dont change sets, checks et publication s'articulent dans un dépôt est décrite sur la page GitNest, et le côté protocole dans l'entrée du glossaire sur MCP.