From 20a5eb06ffdb56b53c49647d13f960d1e3e6175b Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Wed, 2 Sep 2026 19:24:45 +0200 Subject: [PATCH 1/3] fix(cursor): native adapter hardening - Plugin-root discovery with explicit precedence (CURSOR_PLUGIN_ROOT -> CLAUDE_PLUGIN_ROOT -> cwd marker) and stderr diagnostic on miss. - Project cwd precedence incl. CURSOR_PROJECT_DIR/CLAUDE_PROJECT_DIR. - Canonical payload projection for scope dispatchers (tool_name, cwd, tool_input string->object). - `MCP:` canonicalization via a closed server table. - Doc-cache gate reachable on beforeMCPExecution (cursor-only guard). - Per-event native response schemas + exhaustive renderer. - additional_context 10,000-char cap + session-level budget ledger keyed session|event|generation|tool_use_id, fail-open, idempotent truncation. - 23 provenance-labelled fixtures + byte-level stdout tests. - Test hygiene: tmp cwd/HOME, env restore, hex-free nonce for a pre-existing flake. --- MEMORY/LESSON.md | 107 ++++++++++ src/adapters/cursor/context-budget.ts | 144 ++++++++++++++ src/adapters/cursor/context-limit.ts | 115 +++++++++++ src/adapters/cursor/context.ts | 23 ++- .../cursor/interfaces/context-budget.ts | 25 +++ src/adapters/cursor/native-response.ts | 139 +------------ src/adapters/cursor/native-schemas.ts | 161 +++++++++++++++ src/adapters/cursor/normalize.ts | 65 ++++++ src/adapters/cursor/plugin-root.ts | 103 ++++++++++ src/adapters/cursor/respond.ts | 141 ++++++++----- src/runtime/handle.ts | 93 ++++++++- .../lifecycle/aipilot/dispatch-aipilot.ts | 8 +- src/runtime/lifecycle/failure-lesson.ts | 8 +- src/runtime/lifecycle/rules-root.ts | 20 +- test/confirm-codex-provenance.test.ts | 10 +- test/cursor-authentic-fixtures-cases.ts | 51 +++++ test/cursor-authentic-fixtures.test.ts | 123 ++++++++++++ test/cursor-cli-p0.test.ts | 76 +++++-- test/cursor-context-budget-guards.test.ts | 89 +++++++++ test/cursor-context-budget.test.ts | 187 ++++++++++++++++++ test/cursor-context.test.ts | 35 +++- test/cursor-doc-cache-gate.test.ts | 154 +++++++++++++++ test/cursor-mcp-provenance.test.ts | 51 +++++ test/cursor-native-bytes-cases.ts | 127 ++++++++++++ test/cursor-native-bytes.test.ts | 87 ++++++++ test/cursor-native-routing.test.ts | 2 +- test/cursor-plugin-root.test.ts | 98 +++++++++ test/cursor-raw-payload-projection.test.ts | 146 ++++++++++++++ test/cursor-response-channels.test.ts | 38 ++++ test/cursor-runtime-native-boundary.test.ts | 6 +- test/fixtures/cursor/README.md | 64 ++++++ .../cursor/afterFileEdit/01-synthetic.json | 20 ++ .../afterMCPExecution/01-synthetic.json | 21 ++ .../afterShellExecution/01-synthetic.json | 20 ++ .../beforeMCPExecution/01-synthetic.json | 21 ++ .../cursor/beforeReadFile/01-synthetic.json | 19 ++ .../beforeShellExecution/01-synthetic.json | 19 ++ .../01-agent-mode-no-attachments.json | 45 +++++ .../cursor/postToolUse/01-synthetic.json | 22 +++ .../postToolUseFailure/01-synthetic.json | 23 +++ .../cursor/preCompact/01-synthetic.json | 23 +++ .../preToolUse/01-task-main-conversation.json | 38 ++++ .../preToolUse/02-shell-top-level-cwd.json | 39 ++++ .../03-write-subagent-null-transcript.json | 37 ++++ .../preToolUse/04-grep-glob-output-mode.json | 39 ++++ .../cursor/preToolUse/05-read-minimal.json | 36 ++++ .../preToolUse/06-task-resume-interrupt.json | 40 ++++ .../preToolUse/07-multi-root-synthetic.json | 19 ++ .../cursor/sessionEnd/01-synthetic.json | 20 ++ .../01-empty-window-claude-user-config.json | 32 +++ test/fixtures/cursor/stop/01-synthetic.json | 22 +++ .../cursor/subagentStart/01-synthetic.json | 24 +++ .../cursor/subagentStop/01-synthetic.json | 26 +++ .../cursor/workspaceOpen/01-synthetic.json | 11 ++ test/inject-context.test.ts | 34 +++- test/rules-root.test.ts | 70 ++++++- 56 files changed, 2994 insertions(+), 222 deletions(-) create mode 100644 MEMORY/LESSON.md create mode 100644 src/adapters/cursor/context-budget.ts create mode 100644 src/adapters/cursor/context-limit.ts create mode 100644 src/adapters/cursor/interfaces/context-budget.ts create mode 100644 src/adapters/cursor/native-schemas.ts create mode 100644 src/adapters/cursor/plugin-root.ts create mode 100644 test/cursor-authentic-fixtures-cases.ts create mode 100644 test/cursor-authentic-fixtures.test.ts create mode 100644 test/cursor-context-budget-guards.test.ts create mode 100644 test/cursor-context-budget.test.ts create mode 100644 test/cursor-doc-cache-gate.test.ts create mode 100644 test/cursor-native-bytes-cases.ts create mode 100644 test/cursor-native-bytes.test.ts create mode 100644 test/cursor-plugin-root.test.ts create mode 100644 test/cursor-raw-payload-projection.test.ts create mode 100644 test/fixtures/cursor/README.md create mode 100644 test/fixtures/cursor/afterFileEdit/01-synthetic.json create mode 100644 test/fixtures/cursor/afterMCPExecution/01-synthetic.json create mode 100644 test/fixtures/cursor/afterShellExecution/01-synthetic.json create mode 100644 test/fixtures/cursor/beforeMCPExecution/01-synthetic.json create mode 100644 test/fixtures/cursor/beforeReadFile/01-synthetic.json create mode 100644 test/fixtures/cursor/beforeShellExecution/01-synthetic.json create mode 100644 test/fixtures/cursor/beforeSubmitPrompt/01-agent-mode-no-attachments.json create mode 100644 test/fixtures/cursor/postToolUse/01-synthetic.json create mode 100644 test/fixtures/cursor/postToolUseFailure/01-synthetic.json create mode 100644 test/fixtures/cursor/preCompact/01-synthetic.json create mode 100644 test/fixtures/cursor/preToolUse/01-task-main-conversation.json create mode 100644 test/fixtures/cursor/preToolUse/02-shell-top-level-cwd.json create mode 100644 test/fixtures/cursor/preToolUse/03-write-subagent-null-transcript.json create mode 100644 test/fixtures/cursor/preToolUse/04-grep-glob-output-mode.json create mode 100644 test/fixtures/cursor/preToolUse/05-read-minimal.json create mode 100644 test/fixtures/cursor/preToolUse/06-task-resume-interrupt.json create mode 100644 test/fixtures/cursor/preToolUse/07-multi-root-synthetic.json create mode 100644 test/fixtures/cursor/sessionEnd/01-synthetic.json create mode 100644 test/fixtures/cursor/sessionStart/01-empty-window-claude-user-config.json create mode 100644 test/fixtures/cursor/stop/01-synthetic.json create mode 100644 test/fixtures/cursor/subagentStart/01-synthetic.json create mode 100644 test/fixtures/cursor/subagentStop/01-synthetic.json create mode 100644 test/fixtures/cursor/workspaceOpen/01-synthetic.json diff --git a/MEMORY/LESSON.md b/MEMORY/LESSON.md new file mode 100644 index 0000000..09ca687 --- /dev/null +++ b/MEMORY/LESSON.md @@ -0,0 +1,107 @@ +# LESSON.md — Lessons (never reproduce) + + + +- [2026-08-12 16:43] `screenshotsCount` restait à 0 en live. J'ai lu les ROLLOUTS Codex, vu `"name":"exec"` partout et zéro appel de fonction `mcp__*`, et conclu « Code Mode masque les appels MCP aux hooks ». J'ai bâti là-dessus : un diagnostic au proprio, un mandat d'agent pour extraire les `tools.mcp__*` des commandes `exec`, une sonde `CODEX_HOME` jetable qui a échoué à câbler ses hooks (~1 h perdue). C'ÉTAIT FAUX. Un wrapper de 12 lignes sur le binaire déployé (`bin.mjs` → append du stdin dans un fichier → `spawnSync` vers `bin.real.mjs` avec le même stdin) a capturé les vrais payloads en une session : `40 Bash, 34 mcp__fuse_browser__browser_open, 34 browser_navigate, 34 browser_close, 2 browser_screenshot`. Le hook REÇOIT les noms MCP en underscore ; ni `exec` ni `exec_command` n'apparaissent. Un agent `Explore` m'avait pourtant averti (« confirme quel littéral la source émet, sinon ce sera du code mort ») — j'ai relayé l'avertissement mais lancé le mandat quand même au lieu de capturer d'abord. → Une question sur ce qu'un système REÇOIT se tranche par capture À SON ENTRÉE, jamais par déduction depuis ce qui se passe en amont (rollout, log applicatif, doc du fournisseur) : le rollout montre ce que le MODÈLE émet, pas ce que le HOOK reçoit, et les deux diffèrent. Technique par défaut, à faire AVANT tout mandat : wrapper le binaire réellement invoqué (log + délégation fidèle stdin/argv/exit), c'est réversible, ça prend 5 minutes, et ça bat toute sonde d'environnement reconstruite. Chercher compliqué avant simple a coûté un mandat inutile et une prémisse fausse remontée au proprio. [TRIGGERS keyword:rollout,payload,hook,reçoit,déduction,capture,wrapper,sonde,code mort,prémisse] + +- [2026-08-12 12:34] Pour prouver que le 3e chemin était refermé, j'ai sondé `docCacheGate` avec un payload underscore sur `codex`, `claude-code`, `kimi` : « non gaté » partout. J'ai failli en tirer une conclusion — dans un sens comme dans l'autre. Or la sonde ne testait RIEN : sans entrée de cache correspondante, la fonction sort en `null` bien avant d'atteindre le test `GATED_TOOLS` que je voulais exercer. Le témoin l'a révélé : la forme TIRET sur `claude-code`, qui doit bloquer, ressortait elle aussi « non gatée ». La vraie preuve est venue du test de l'exécuteur, qui POSE d'abord une fixture de cache (`index.json` + `docs/.md`) puis obtient `deny` sur codex et `null` sur claude-code. → Sonder une fonction à sortie précoce (cache absent, flag off, fichier manquant) ne prouve rien tant qu'on n'a pas construit l'état qui l'active. Règle : TOUJOURS accompagner un contrôle négatif d'un TÉMOIN POSITIF connu ; si le témoin ne se déclenche pas non plus, la sonde est morte et son résultat ne vaut ni pour ni contre. Un « tout est vert » obtenu sur un chemin jamais atteint est le faux positif le plus coûteux du lot. [TRIGGERS keyword:sonde,gate,null,non gaté,témoin,fixture,cache,sortie précoce,faux positif] + +- [2026-08-12 12:28] Le sniper avait conclu « aucun troisième chemin : TOUS les consommateurs (SHOT_TOOLS, classifyExplore, docSourceOf, NAV/SCROLL/GEMINI) reçoivent `event.tool` déjà canonicalisé » — remontée d'appelants sérieuse, et fausse. Le challenger a trouvé `runtime/lifecycle/aipilot/doc-cache-gate.ts:45` : `String(payload.tool_name ?? "")`, payload BRUT, comparé à un regex en tiret `context7__query-docs`, atteint par une branche PARALLÈLE (`handle.ts:65` → `handle-scope-async.ts:21` → `dispatchAipilot(event, payload, …)`) qui court-circuite `normalizeEvent`. Donc le gate doc-cache ne se déclenchait toujours pas sur Codex. La méthode du sniper ne pouvait pas le voir : partir des consommateurs CONNUS et remonter ne trouve jamais un lecteur qu'on n'a pas listé. → Chercher un chemin d'ingestion oublié se fait dans les DEUX sens : (1) descendre des consommateurs connus vers leur source, (2) grep TOUS les lecteurs bruts du champ (`payload.tool_name`, `tool_name`) et prouver un par un qu'ils sont normalisés ou inoffensifs. Le sens (2) seul est exhaustif ; le sens (1) seul donne un faux « complet » très convaincant. Vaut pour tout garde qu'on prétend avoir posé « partout ». SUITE 12:44 — le sens (2) a été fait sérieusement (15 lecteurs bruts recensés, par le sniper ET par un `explore-codebase` indépendant en parallèle), et un CINQUIÈME chemin a quand même échappé : `lifecycle/aipilot/cache-doc.ts:15`, regex tiret `context7__query-docs` testé contre `block.name` — le nom d'outil d'un bloc `tool_use` lu depuis un TRANSCRIPT JSONL, pas depuis `payload.tool_name`. Le grep exhaustif portait sur le bon fichier mais sur le mauvais CHAMP. → L'exhaustivité d'un grep se mesure au champ cherché, pas au nombre de résultats : une même donnée (ici un nom d'outil MCP) entre par plusieurs portes de noms différents (`payload.tool_name`, `tool_use.name`/`block.name` de transcript, `agent_transcript_path`, matchers de hooks.json). Avant de déclarer un balayage complet, énumérer d'abord tous les NOMS DE CHAMP par lesquels la donnée peut arriver, puis grepper chacun — sinon on obtient un « 15 lecteurs, tous vérifiés » exact et incomplet. [TRIGGERS keyword:grep,exhaustif,champ,block.name,transcript,tool_use,balayage,complet] [TRIGGERS path:src/runtime/**/*.ts keyword:troisième chemin,tous les consommateurs,payload,tool_name,brut,normalizeEvent,exhaustif,garde] + +- [2026-08-12 12:15] J'ai reçu une task-notification « sniper terminé — RAS, aucun bug, zéro modification », je l'ai prise pour la fin de son travail, et j'ai conclu qu'il avait esquivé la prototype pollution que je lui avais explicitement demandée. J'ai alors (a) sondé moi-même et trouvé le défaut réel — 9 clés héritées corrompues sur 10 —, (b) lancé un agent d'écriture (`sniper-faster`) pour le corriger, (c) accusé le sniper dans mon rapport au proprio, (d) écrit une leçon sur son « PASS sans exécution ». TOUT ÇA ÉTAIT FAUX : le sniper n'avait pas fini. Il a trouvé le même défaut, l'a reproduit, corrigé en `Object.create(null)` et couvert par un test `5b` — c'est LUI l'auteur de l'écriture de 12:10:36 que j'ai attribuée à un autre agent sans vérifier. J'ai donc lancé un écrivain concurrent PENDANT que le sniper écrivait, violant ma propre règle « sniper après, jamais pendant ». Collision évitée de justesse parce que l'agent de fix a re-vérifié sa prémisse sur disque et s'est arrêté. → Une task-notification n'est PAS une fin de travail : elle se déclenche à chaque fois qu'un agent s'arrête sans enfant vivant, et l'agent peut reprendre. Avant de conclure qu'un agent a raté quelque chose, ou de lancer quoi que ce soit sur son périmètre : lui DEMANDER son état, et attribuer toute écriture constatée à un auteur PROUVÉ (mtime + qui était actif), jamais au premier suspect. Accuser un agent à tort coûte un correctif redondant, un rapport faux au proprio, et une leçon à réécrire. [TRIGGERS tool:Agent keyword:notification,terminé,idle,RAS,zéro modification,concurrent,sniper,accusé,attribution] + +- [2026-08-12 12:07] Un exécuteur s'est déclaré « idle/available » sans avoir écrit une seule ligne : le gate de fraîcheur APEX DU HARNAIS QU'ON CORRIGEAIT avait bloqué son `Write` (y compris dans le scratchpad), il avait lancé un `research-expert` pour se débloquer — et la notification de fin de CE sous-agent est remontée au LEAD, pas à son parent. L'exécuteur attendait donc un signal déjà arrivé, ailleurs. Interblocage silencieux : aucune erreur, aucun timeout, juste un agent qui ne repart jamais. Débloqué en lui renvoyant le verdict à la main. → Un « idle » sans livrable n'est pas une fin de tâche, c'est une alarme : demander l'état réel AVANT de conclure quoi que ce soit, et vérifier soi-même `git status` + le scratchpad plutôt que de croire un statut. Corollaire structurel : la notification d'un sous-agent lancé par un sous-agent remonte au lead — quand un agent délégué en spawne un autre, prévoir que c'est le lead qui recevra le signal et devra le relayer (arrivé 3 fois dans la même session ; à chaque fois le parent attendait un verdict déjà chez moi). SUITE 12:28 — l'autre bout du même tuyau : DEUX challengers d'affilée ont rédigé leur rapport en SORTIE TEXTE, qui ne remonte pas au lead. Le premier avait terminé son analyse complète depuis longtemps ; je l'ai cru muet, puis mort — `ListAgents` répondait « No reachable agents » alors qu'il a répondu normalement à la sonde suivante. → Deux règles : (a) inscrire dans le BRIEF INITIAL de tout agent que son SEUL canal de retour est `SendMessage` vers `team-lead`, sa sortie texte étant invisible ; (b) ne jamais conclure à la mort d'un agent sur `ListAgents` — le sonder par message d'abord, c'est gratuit et ça a détrompé deux fois. SUITE 12:15 — corollaire opérationnel confirmé deux fois dans la même session : un agent annoncé terminé ou « idle » peut encore écrire (cf. la leçon 12:15 : le sniper a réécrit `src/runtime/mcp-tool-name.ts` à 12:10:36 APRÈS m'avoir notifié « terminé, zéro modification »). D'où deux gardes systématiques : (a) tout mandat qui affirme « reproduit à l'instant » impose à son destinataire de re-vérifier la prémisse sur DISQUE avant sa première écriture et de s'arrêter si elle est tombée — c'est ce réflexe, et lui seul, qui a évité une écriture concurrente ce jour-là ; (b) encadrer toute mesure d'un `stat` des mtimes AVANT et APRÈS — mtimes identiques = mesure valide, sinon elle est à refaire. [TRIGGERS tool:Agent keyword:idle,available,bloqué,notification,sous-agent,attente,freshness,gate,concurrent,mtime,prémisse] + +- [2026-08-03 13:29] L'issue #87 dormait OUVERTE depuis 14 jours, sans un seul commentaire : une exécution de commande arbitraire dans `harness check`, reproduite par son auteur sur `0.1.79` (`execSync(\`git show ":${path}"\`)` avec `path` = nom de fichier stagé, donc contrôlé par l'auteur du commit ; `core.quotepath` échappe `"` et `\` mais PAS les backticks ni `$(...)`). Correctif = une ligne (`execFileSync` + argv array), rapport de qualité, PR proposée par l'auteur. Pendant ces deux semaines on a publié 0.1.87 puis 0.1.88 sans jamais regarder les issues, et le README recommande justement `harness check` en pre-commit — le scénario exact de l'exploitation. → Les issues du repo font partie de l'état du projet, pas d'un canal annexe : `gh issue list --state open` AVANT toute release, et un rapport de sécurité reproduit se traite avant les fonctionnalités, pas après. Corollaire d'audit : quand une injection est trouvée à un endroit, balayer TOUS les sites de la même forme et prouver l'origine de chaque donnée interpolée (ici 9 sites revus, un seul exploitable — mais le verdict « sûr » s'établit en remontant les appelants, jamais à vue). [TRIGGERS keyword:issue,sécurité,injection,execSync,shell,release,publier,CVE] + +- [2026-08-03 12:57] J'ai annoncé au proprio « 1094 tests, 0 échec, tsc propre » puis lancé le commit — alors qu'entre-temps le code NE COMPILAIT PLUS : `tsc` sortait `handle.ts(84,5): Cannot find name 'handleConfirmSubmit'`, l'import ayant disparu quand le sniper a scindé `confirm-state.ts`. Mon vert datait d'AVANT son refactor ; je l'ai reporté comme s'il était encore vrai. Deux filets ont sauvé la mise : l'agent commit n'a rien commité, et le binaire déployé chez le proprio avait été buildé avant la casse — donc rien de cassé côté machine, mais c'est de la chance, pas de la méthode. → Un résultat de test/typecheck a une DATE : dès qu'un agent a retouché `src/` après lui, il est périmé. Re-lancer `tsc` + tests JUSTE AVANT d'annoncer un état ou de déclencher un commit, jamais citer une mesure prise avant la dernière écriture. Corollaire : après tout refactor qui déplace/scinde des modules, `tsc --noEmit` est le contrôle minimal — un import perdu ne se voit ni au diff relu ni aux tests si la suite ne couvre pas ce chemin. SUITE 2026-08-12 12:07 : la règle vaut AUSSI pour les ARTEFACTS DE PREUVE, pas seulement pour tsc/tests. Un exécuteur a livré une baseline de caractérisation « après » comme pièce maîtresse de non-régression — générée à 12:01:51, alors qu'il avait retouché le module à 12:02:54. Il avait consciencieusement relancé tsc et les tests après sa dernière écriture, mais pas sa propre preuve. Attrapé en comparant les mtimes de l'artefact et des fichiers source (`stat -f "%Sm %N"`), puis régénéré : sha256 identique, donc conclusion inchangée — mais elle reposait sur de la chance, pas sur une mesure valide. → Un artefact de preuve (baseline, snapshot, capture différentielle) se date et se re-génère comme un test. Contrôle systématique avant d'accepter une preuve d'un agent : `stat` l'artefact vs les fichiers qu'il prétend caractériser, et le régénérer soi-même si l'ordre ne tient pas. Un agent qui relance ses tests mais pas sa preuve croit sincèrement être à jour. SUITE 12:50 — variante bénigne mais répétée deux fois le même jour : j'ai rapporté « 1107 pass » au proprio alors que la sortie disait `1106 pass / 1 skip / Ran 1107 tests`. `Ran N` compte les tests EXÉCUTÉS (pass + skip), pas les réussis. Sans conséquence ici, mais c'est un chiffre qu'on cite au proprio à chaque livraison. → Lire la ligne de résumé `bun test` en entier et citer `pass` et `skip` séparément, jamais le total de `Ran`. Un chiffre de livraison se recopie depuis la sortie, il ne se reconstitue pas de mémoire. [TRIGGERS keyword:tsc,typecheck,vert,tests,commit,refactor,scinder,import,périmé,baseline,snapshot,preuve,mtime] + +- [2026-08-03 11:48] J'ai lancé sniper (sur `src/`+`test/`) et un agent doc (sur `*.md`) EN PARALLÈLE, périmètres de fichiers strictement disjoints — donc aucun conflit d'écriture, et sur ce plan ça a marché. Mais le sniper a SCINDÉ `confirm-state.ts` en cours de route pendant que l'agent doc écrivait `docs/config.md` et `docs/adapters.md` avec des chemins pointant vers l'ancien fichier : deux livrables cohérents chacun, incohérents ENSEMBLE. Rattrapé de justesse par un grep croisé avant le déploiement. → Des périmètres disjoints protègent des collisions d'écriture, PAS des dépendances sémantiques : quand un agent documente/référence ce qu'un autre restructure, la doc doit passer APRÈS le refactor, ou le refactoreur doit avoir interdiction de renommer/déplacer. Contrôle systématique avant de conclure un lot parallèle : grep des chemins/symboles cités par l'un dans ce que l'autre a produit. [TRIGGERS tool:Agent keyword:parallèle,disjoint,périmètre,doc,refactor,scinder,renommer,chemin] + +- [2026-08-03 11:42] Le challenger a REFUTÉ « un agent ne peut pas s'auto-approuver » sur le mécanisme `CONFIRM`, avec un bypass reproduit sur le binaire déployé : `consumeConfirmToken` ne consulte JAMAIS G0 (G0 ne vit qu'à la POSE du jeton), donc l'agent saute l'étape humaine — il calcule lui-même le hash (algo en clair dans le repo), écrit `{confirmToken}` dans `~/.fuse-harness/cache/sessions/*.json` via un `node -e`, et rejoue l'action : autorisée. Le seul rempart, `SESSION_STATE_FRAGMENT`, est un match de sous-chaîne littérale, évité par `"cache/ses" + "sions"` — un filtre lexical, pas un contrôle d'intégrité. Constat de fond : TOUT gate dont l'état vit dans un fichier que l'agent peut écrire est un garde-fou de DISCIPLINE (contre l'erreur, l'agent coopératif), jamais un contrôle de SÉCURITÉ — le harnais n'est pas une sandbox, et ça vaut aussi pour le pipeline design et la fraîcheur APEX. → Ne jamais présenter un tel mécanisme comme une barrière : documenter noir sur blanc ce qu'il garantit et ce qu'il ne garantit pas, et vérifier que le garde est évalué à CHAQUE point qui accorde le privilège (poser ET consommer), pas seulement au premier auquel on a pensé. Corollaire attrapé par le sniper dans le même lot : sur trois points d'entrée ajoutés à des chemins de hook, un seul avait son `try/catch` — une exception dans un hook fait tomber le harnais ENTIER, donc la protection se met sur tous les points d'entrée d'un coup, pas sur celui qu'on écrit en dernier. [TRIGGERS keyword:auto-approbation,bypass,jeton,token,état,sandbox,sécurité,discipline,try/catch,hook] + +- [2026-08-03 11:12] G0 (le garde-fou qui interdit de poser un jeton `CONFIRM` pendant qu'un sous-agent tourne) a d'abord été un compteur `depth ± 1` : un `SubagentStop` dupliqué par le fan-out multi-plugins le fait tomber à 0 pendant qu'un agent tourne encore, et le garde-fou S'OUVRE. Remplacé par une écriture monotone `seenAt = Math.max(prev, now)` — idempotente, sans décrément, donc rien à désynchroniser. Correction réglée. MAIS l'exécuteur a fixé la fenêtre à 30 min, et ça rend le mécanisme inutilisable pour un proprio qui lance des agents en continu : le moindre sous-agent gèle toute confirmation une demi-heure. Le piège de fond : sans décrément, la fenêtre doit couvrir la DURÉE DE VIE du sous-agent, pas la latence d'écriture — d'où une valeur énorme, techniquement juste et pratiquement morte. → Un garde-fou se valide sur DEUX axes, jamais un seul : (1) ne peut-il jamais s'ouvrir à tort, (2) reste-t-il utilisable dans le rythme réel du proprio. Quand la sûreté n'est atteinte qu'au prix d'un paramètre qui neutralise la fonctionnalité, ce n'est pas un réglage à choisir seul — remonter l'arbitrage, ou supprimer le paramètre (ici : deux compteurs croissants `starts`/`stops`, actif tant que `starts > stops`, aucune fenêtre — écarté finalement, car un fan-out asymétrique N≠M gèlerait le mécanisme POUR TOUJOURS, pire que la fenêtre). SUITE 11:18 : j'allais faire écrire la nouvelle valeur en constante nue ; le proprio a demandé « ça doit être basé sur notre système TTL non ? » — et il avait raison, `src/config/ttl.ts` expose déjà `resolveTtlSec(env, key)` + `ttlLabel()`. → Avant d'introduire le moindre paramètre de durée/seuil, chercher l'infrastructure de configuration DÉJÀ présente et y brancher une clé DÉDIÉE : réutiliser le helper (DRY, réglable sans recompiler) sans réutiliser la clé d'un autre besoin (`FUSE_ENFORCE_TTL_SEC` gouverne la fraîcheur des preuves — coupler les deux ferait qu'allonger le TTL de recherche allongerait le gel des confirmations). SUITE 11:24 : j'avais prescrit `resolveTtlSec(env, "MA_CLE")` en croyant le helper paramétrable — il ne l'est qu'à moitié, son fallback est figé sur `DEFAULT_TTL_SEC = 120` quelle que soit la clé passée, donc un défaut de 300 était inexprimable. L'exécuteur est descendu d'un cran sur `parseEnvInt` (le primitif que `resolveTtlSec` utilise lui-même) et l'a documenté. → Avant de prescrire la réutilisation d'un helper, LIRE son corps et pas seulement sa signature : un paramètre exposé ne garantit pas que tout le comportement suit (ici la clé est paramétrable, le défaut non). Le bon repli est le primitif sous-jacent, jamais une réécriture parallèle. [TRIGGERS keyword:garde-fou,fail-closed,fenêtre,window,compteur,monotone,sous-agent,G0,inutilisable] + +- [2026-08-03 10:53] Sur le point [8] (confirmation `CONFIRM `), j'ai enchaîné les mesures — `ask` sous Kimi, `systemMessage` sous Codex, payloads, P0 — sans jamais rien construire, jusqu'à ce que le proprio explose : « on est dans du code, c'est un hack à faire, simple ». Il a nommé la bonne étape que je ne voyais pas : un PROTOTYPE hors harnais, branché dans son vrai Codex. Écrit en 20 min, il a prouvé le mécanisme en conditions réelles (deny → l'humain tape le code → allow → fichier créé), et il a révélé ce qu'aucune mesure n'aurait donné : Codex utilise `apply_patch`, pas `touch`, donc le gate DOIT juger le contenu de l'action et jamais le nom d'outil. Bonus : le prototype a exposé un défaut de conception avant qu'il n'entre dans le harnais — vérifier le jeton sur le code court de 4 hex (16 bits) autorise une action qui COLLISIONNE ; le jeton doit porter le hash complet, les 4 caractères ne sont qu'un élément d'interface. → Quand une fonctionnalité est bloquée par une chaîne de mesures qui n'en finit pas, arrêter de mesurer et écrire un prototype JETABLE hors du système à modifier : il tranche plus vite, il se teste en vrai, et il fait tomber les défauts de design gratuitement. Signal d'alarme à s'appliquer : trois tours sans une ligne de code livrée = changer de mode. Corollaire vu au portage (11:04) : quand un chemin de code ne propage pas `home`, un témoin écrit dans le HOME RÉEL et y laisse des fichiers d'état orphelins (`~/.fuse-harness/cache/sessions/session--*.json`) — un test doit soit isoler son HOME, soit nettoyer ce qu'il crée, jamais laisser des résidus sur la machine du proprio. [TRIGGERS keyword:mesure,prototype,hack,simple,tourner en rond,tranche,confirmation,CONFIRM,orphelin,HOME] + +- [2026-08-03 08:57] Un exécuteur a livré un contre-témoin « avant/après » concluant `IDENTICAL` sur `node dist/cli/bin.mjs hook ` — sauf que le harnais DÉDUPLIQUE : deux invocations successives dans le MÊME cwd renvoient 11212 octets puis **0 octet** (vérifié : même avec des `session_id` différents, c'est le cwd qui porte la dédup). Son `diff` comparait donc très probablement du vide à du vide. Refait avec un cwd NEUF par capture : 11212 (claude-code) et 19983 (codex) octets, identiques avant/après — la conclusion tenait, la preuve non. → Un témoin différentiel doit d'abord prouver qu'il MESURE quelque chose : exiger la TAILLE non nulle de chaque capture avant de lire le `diff`, et isoler l'état (cwd/session neufs) entre les deux branches. Un `diff` vide sur deux sorties vides est le faux vert le plus facile à produire et le plus difficile à voir. [TRIGGERS keyword:contre-témoin,avant/après,differential,IDENTICAL,diff,capture,dédup,burst] + +- [2026-08-03 08:35] Capture d'un payload de hook Kimi (0.31.1) : un `KIMI_CODE_HOME` isolé ne suffit PAS — sans le `config.toml` du proprio recopié, `kimi -p` meurt sur « No model configured » (les blocs `[providers]`/`[models]` sont indispensables), et le token n'est PAS dans `oauth/kimi-code` (fichier VIDE, 0 octet) mais dans `credentials/kimi-code.json`. Le run a ensuite échoué sur « authorization grant is invalid » : `expires_at` du fichier était périmé de ~33 h — un défaut d'AUTH que j'ai failli imputer au quota que le proprio venait de recharger. Acquis quand même : `UserPromptSubmit` est tombé AVANT l'appel réseau, donc `session_id = "session_"` est mesuré (passe `SID_RE`, l'ancrage par session tient sous Kimi) ; le `PreToolUse`, lui, arrive APRÈS l'auth et reste non mesuré. → Recette Kimi : `KIMI_CODE_HOME` isolé + `config.toml` du proprio concaténé avec les `[[hooks]]` (syntaxe plate : `event`/`command`/`timeout`) + `credentials/kimi-code.json` copié PUIS supprimé + `-p … < /dev/null`. Et la règle générale : avant d'accuser le quota, lire `expires_at` du credential — et savoir quels events précèdent l'auth (on en tire une mesure même sur un run mort). Divergence trouvée au passage : sous Kimi `prompt` est un TABLEAU `[{type,text}]`, une string ailleurs — les 4 sites qui font `typeof === "string"` tombent silencieusement sur `""`. CORRECTIF 09:02 : j'ai annoncé au proprio « ton token est expiré », il a répondu « il est pas expiré » — il avait raison. `credentials/kimi-code.json` porte `expires_at = 0` et `oauth/kimi-code` est vide : l'auth ne vit PAS sur le disque, elle est tenue par la session interactive. Donc un `KIMI_CODE_HOME` isolé n'est PAS authentifiable par copie, quoi qu'on copie — la mesure doit se faire DEPUIS la session du proprio (sonde déclarée dans son `config.toml`, sauvegardé puis restauré par lui). Règle : `expires_at = 0` ou un fichier de token vide ne veut pas dire « expiré », il veut dire « le disque n'est pas la source de vérité » — arrêter d'y chercher un credential et changer de canal de mesure. [TRIGGERS tool:Bash keyword:kimi,KIMI_CODE_HOME,quota,oauth,credentials,session_id,prompt,payload] + +- [2026-08-03 01:26] Un exécuteur a SAUTÉ la capture différentielle exigée par le mandat, en justifiant par ma propre leçon du 22:35 : « le log de leçons du proprio classe la preuve statique au-dessus de l'échantillonnage pour un changement localisé ». Or cette leçon dit « statique D'ABORD, capture APRÈS comme contre-témoin », jamais « statique AU LIEU DE ». Une leçon citée de travers devient une permission — et c'est exactement le contre-témoin qui a rattrapé la régression `recordPost` deux heures plus tôt, sur un changement lui aussi jugé localisé. → Une leçon qui ORDONNE deux étapes ne doit jamais pouvoir se lire comme un arbitrage entre elles : l'écrire en séquence explicite (« A puis B », pas « A vaut mieux que B »). Et côté lead : quand un exécuteur invoque une règle du repo pour retirer une exigence du mandat, relire la règle avant d'accepter — c'est le signal d'un raccourci habillé en doctrine. [TRIGGERS tool:Agent keyword:preuve statique,échantillonnage,capture différentielle,leçon,localisé,jugé superflu] + +- [2026-08-03 00:33] Le proprio décrit son outillage Codex : « functions.exec → tools.exec_command → sed -n ». J'en ai déduit que le hook reçoit `tool_name: "exec_command"`, j'ai constaté 0 occurrence d'`exec_command` dans `src/`, et j'ai fait implémenter un `canonicalizeCodexShellTool` pour le mapper vers `"Bash"`. Capture du payload RÉEL ensuite (2 runs, `gpt-5.6-sol` ET `gpt-5.6-terra`) : `tool_name` vaut **`"Bash"`** — Codex relabelle AVANT d'appeler le hook, seul `tool_use_id: "exec-…"` garde la trace. Ce que l'agent décrit, c'est SA vue du tool ; le hook a son propre contrat. La moitié « reconnaître exec_command » du correctif ne corrige donc rien de mesurable (l'autre moitié — créditer la lecture dans `recordPost`, qui ne testait que `"Read"` — était bien le vrai fix). J'avais la sonde de capture opérationnelle AVANT de lancer l'implémentation : 5 minutes auraient recadré le correctif. → Un nom d'outil rapporté par un agent (ou lu dans un binaire) n'est PAS le `tool_name` du payload : capturer le payload réel avant d'écrire la moindre ligne qui en dépend. Corollaire de méthode : quand une sonde de capture existe déjà dans la session, la passer AVANT de déléguer, jamais après — l'ordre mesure→code coûte 5 min, l'ordre code→mesure coûte le correctif entier. [TRIGGERS path:src/runtime/normalize.ts keyword:tool_name,exec_command,alias,relabel,payload,harnais,nom d'outil] + +- [2026-08-03 00:26] Capture d'un payload `UserPromptSubmit` réel sous Codex : mon hook-sonde n'a RIEN produit et j'ai failli conclure « la syntaxe TOML `[[hooks.UserPromptSubmit.hooks]]` est fausse ». Elle était juste — sans `--dangerously-bypass-hook-trust`, Codex ignore le hook SILENCIEUSEMENT, sans un mot d'avertissement. Avec le flag, il annonce `hook: UserPromptSubmit` et la capture tombe. Piège précédent du même run : un `CODEX_HOME` isolé n'a pas d'`auth.json` → 401 en boucle sur `wss://api.openai.com` (copier celui de `~/.codex`, et le SUPPRIMER après — c'est un credential). Autres pièges confirmés : `timeout` n'existe pas sur macOS, `codex exec` pend sans `< /dev/null`. → Recette de capture d'un payload de hook Codex : `CODEX_HOME` isolé + `auth.json` copié + hook en `config.toml` + `--dangerously-bypass-hook-trust` + `< /dev/null`. Et la règle générale : un mécanisme de sécurité qui refuse en silence rend un test négatif INDISTINGUABLE d'une erreur de configuration — avant de conclure « ma config est fausse », chercher le flag/mode qui rend le refus visible. [TRIGGERS tool:Bash keyword:codex exec,CODEX_HOME,hook,sonde,payload,capture,401,trust] + +- [2026-08-02 23:58] Sur « on couvre ça aussi » (3 mots), j'ai lancé l'élargissement de `UI_FILE_RE` à `.html`/`.css` — donc l'extension de `uiDesignSkillGate`, qui exige une lecture de skill + Context7 ET Exa, et qui s'applique à TOUS les agents. Le proprio a tué l'agent en cours : il voulait l'inverse — « pour rédiger un html on a pas besoin de gate, juste confirmation que design-system est là et que les gates 0 à x sont suivis ». Un gate de PIPELINE (phase franchie), pas de COMPÉTENCE, et limité au design-agent. Deux correctifs opposés derrière la même approbation. → Quand l'accord tient en trois mots, reformuler la RÈGLE MÉTIER en une phrase AVANT de lancer, jamais le patch technique : « tout agent devra avoir lu une skill design avant d'écrire un HTML » aurait été rejeté en deux secondes. Le nom du symbole à modifier ne dit rien de la règle qu'il applique — c'est la règle qui se valide, pas la ligne de code. [TRIGGERS tool:Agent keyword:on couvre,aussi,élargir,ajouter à la regex,vas y,ok] + +- [2026-08-02 23:50] Deux rapports de bug d'affilée disaient « sous Codex X passe, sous Claude Code X est bloqué » — les DEUX se sont révélés faux sur la cause. [2] visait un filtre `endsWith("design-system.md")` en réalité redondant (les 2 gates qu'il protège se gardent déjà elles-mêmes par le même test) : le patch aurait été un no-op. Puis le symptôme « `.html` écrit en phase 0 » : `UI_FILE_RE = /\.(tsx|jsx|scss|vue|svelte)$/` (skill-gate.ts:16) ne couvre PAS `.html` — donc `Write .html` en phase 0 est ALLOW sous Claude Code AUSSI. Il n'y avait aucune divergence de parité, juste le comportement nominal. → Devant un rapport « harnais A contourne ce que harnais B bloque », le PREMIER contrôle est d'exécuter le cas nominal sur le harnais de RÉFÉRENCE et de vérifier qu'il bloque vraiment. Deux minutes qui invalident ou recadrent le rapport avant toute lecture de code. Corollaire : un gate qui « protège » un cas ne le protège que si son propre prédicat d'entrée matche — lire la regex/le garde-fou AVANT de croire à une fuite. [TRIGGERS path:src/policy/**/*.ts keyword:parité,contourne,bypass,divergence,harnais,ALLOW,DENY,regex,prédicat] + +- [2026-08-02 22:43] Les 2 témoins censés verrouiller la régression `handle-post.ts` appelaient `designGate` DIRECTEMENT, jamais `handlePost`. Ils passaient au vert, leurs noms disaient « production call site », et ils n'auraient RIEN vu si un refactor remettait `designGate(payload, files[0] ?? event, …)` — soit exactement le bug qu'ils prétendaient interdire. Vu par le sniper en relecture, pas par le vert. Corrigé en passant par `handlePost` : sous mutation, `Expected: 3 / Received: 2`, les 2 échouent. → Un test de non-régression doit traverser le POINT D'ENTRÉE qui portait le bug, pas la fonction interne qu'il appelle : tester l'unité prouve une propriété de l'unité, jamais le CÂBLAGE, et c'est presque toujours le câblage qui casse. Contrôle qui tranche : muter la ligne de production incriminée et exiger l'échec — si le test reste vert, il ne verrouille rien, quel que soit son nom. [TRIGGERS path:test/**/*.ts keyword:témoin,non-régression,câblage,point d'entrée,handlePost,mutation,verrou] + +- [2026-08-02 22:35] Le proprio a redit « je ne veux pas de régression » : j'ai lancé une capture différentielle de 960 cellules (~15 min) en annonçant « ce n'est pas encore une preuve ». Il a dû me reprendre — « informatique = math, tout est prouvé ». Il avait raison : la démonstration tenait en 3 lemmes et 5 minutes (le code modifié est inatteignable hors `apply_patch` car `event.files` n'a qu'UN site d'écriture, gardé ; `fanOutFiles` retourne la même référence sinon ; le seul maillon non trivial était l'équivalence de l'objet reconstruit). L'échantillonnage a ensuite CONFIRMÉ, sans rien apprendre de plus. → Quand le changement est localisé, faire la preuve STATIQUE d'abord — inatteignabilité par grep exhaustif des sites d'écriture, puis réduction à l'identité — elle est gratuite, exhaustive, et dit QUELLES hypothèses la portent. La capture différentielle vient APRÈS, comme contre-témoin d'un lemme qu'on croit vrai en le relisant, jamais à sa place. Inverser l'ordre coûte 15 min et livre une garantie plus faible. [TRIGGERS keyword:zéro régression,preuve,prouve,garantie,différentiel,capture,échantillon] + +- [2026-08-02 22:26] Le proprio a demandé « explique mes décisions clairement » : j'ai rendu une fiche de 40 lignes (contexte, chemins de code, coût/bénéfice, options) — verdict : « c'est pas clair ni concis ». Reformulé en 8 lignes : le choix, une phrase de conséquence réelle, ma reco. Même contenu utile, le reste était de la justification de MON analyse, pas de l'information pour SA décision. → « Explique » ne veut pas dire « développe » : une décision se rend en choix + conséquence + reco, 2-3 lignes chacune. Le détail technique (fichier:ligne, mécanisme) n'a sa place que s'il CHANGE le choix — sinon il le noie. Signal sur soi : si ma réponse à une demande de décision dépasse 10 lignes, je suis en train d'exposer mon travail au lieu de servir le sien. [TRIGGERS keyword:explique,décision,clairement,concis,tranche,recommandation] + +- [2026-08-02 22:20] Un exécuteur a livré le correctif apply_patch avec 1031 pass / 0 fail / typecheck vert, tableau d'idempotence à l'appui — et il introduisait une régression Codex-only. En ajoutant `files: undefined` aux events fannés, il faisait basculer apply_patch d'une branche `else if` de `recordPost` à une autre : l'ancienne résolvait le chemin (`join(cwd, path)`) et était promote-only ; la nouvelle fait `readFileSync(fp)` BRUT et peut dégrader l'état. Or les chemins apply_patch sont relatifs (doc Codex : « File references can only be relative, NEVER ABSOLUTE ») → `catch` → `return` → état design plus jamais promu, silencieusement. Aucun test ne couvrait ce chemin, donc le vert ne disait rien. → Faire tomber un event dans une AUTRE branche d'une chaîne `else if` est un changement de comportement complet, jamais un détail d'implémentation : lire les DEUX branches en entier et diffé ce qu'elles font (résolution de chemin, monotonie, effets de bord), pas seulement constater que la nouvelle « est atteignable ». Corollaire : un vert de suite ne vaut que pour les chemins COUVERTS — avant d'accepter un « 0 fail », demander quel test exerce précisément la ligne qu'on vient de dévier. [TRIGGERS path:src/runtime/**/*.ts keyword:else if,branche,fan-out,readFileSync,chemin relatif,join(cwd,promote-only,0 fail] + +- [2026-08-02 22:11] J'ai briefé un agent en « revue LECTURE SEULE, dis-moi si c'est GO », puis envoyé le mandat d'exécution en 2e message : il avait déjà terminé, le SendMessage est arrivé après sa fin et a coûté un cycle complet de relance (~2 min + re-lecture de 9 fichiers). → Quand le correctif SERA appliqué quoi qu'il arrive, le mandat d'exécution va dans le brief INITIAL avec une porte interne : « rends ton verdict GO/NO-GO d'abord, puis APPLIQUE — sauf NO-GO, auquel cas tu t'arrêtes ». Un agent qui rend un avis et meurt n'est pas un agent qu'on « continue » : c'est un agent qu'on relance. Ne scinder avis et exécution que si un NO-GO changerait MATÉRIELLEMENT le travail à faire. [TRIGGERS tool:Agent keyword:lecture seule,revue,verdict,GO,NO-GO,ne modifie aucun fichier] + +- [2026-08-02 22:11] Le proprio a livré un rapport de bug complet (cause racine + patch 2 lignes + matrice de tests) sur l'absence de `htmlCssOnlyGate` dans `designFilesGate`. Le diagnostic était juste, mais DEUX choses n'y étaient pas et n'auraient jamais été vues en appliquant le patch tel quel : (a) le docstring du module documentait l'exclusion comme une décision assumée (« htmlCssOnlyGate stays excluded (owner D2) ») — un patch silencieux aurait laissé un commentaire qui MENT à la relecture suivante ; (b) la doc officielle Codex dit que PostToolUse « can't undo side effects from a tool that already ran », donc le volet POST du rapport ne bloque RIEN, il ne restaure qu'un avertissement. → Un rapport de bug bien ficelé, même du proprio, se vérifie sur les DEUX axes que sa forme rend invisibles : le commentaire/doc qui justifiait le comportement actuel (le corriger AVEC le code, jamais après), et ce que la plateforme permet réellement au point d'accroche choisi. Un patch qui laisse derrière lui une doc contradictoire coûte plus cher que le bug. [TRIGGERS path:src/runtime/**/*.ts keyword:rapport de bug,patch fourni,owner,KNOWN GAPS,documented,out of scope] + +- [2026-07-30 22:05] Fin de session : j'ai terminé QUATRE réponses d'affilée par une question au proprio (« je pousse ? », « je le relance ? », « je le corrige ? ») sur des actions RÉVERSIBLES et déjà implicitement commandées. Il a dû me le répéter 4 fois, puis exploser. Chaque question rendait la main pour rien et cassait le fil. → Ne poser de question QUE si l'action est irréversible (tag `v*`→publish, force-push, suppression) ou si deux lectures mènent à des travaux MATÉRIELLEMENT différents. Sinon : agir, puis rapporter en 2 lignes ce qui a été fait. Une action réversible mal devinée coûte un `git revert` ; une question de trop coûte la confiance. Corollaire : un « continue »/« vas y » couvre la SUITE ÉVIDENTE du travail en cours, pas seulement l'étape suivante — ne pas redemander à chaque palier. [TRIGGERS keyword:je pousse,je lance,tu veux que,je le fais,dis-moi,confirmation,question] + +- [2026-07-30 20:12] Le proprio a demandé « qu'est-ce que tu penses de ce brief ? », puis « garantis-moi zéro régression ». J'ai construit tout l'appareil de preuve (golden 32 cellules, fixtures hermétiques, test de mutation) — 4 h — SANS JAMAIS faire le fix, qui tenait en 3 lignes. Il a fallu « en gros tu as foutu quoi » puis « bordel tu joue à quoi » pour que je m'en aperçoive. Le filet était bon ET hors sujet : personne n'avait demandé de le livrer avant le correctif. → Une exigence de qualité (« garantis-moi X ») ne remplace JAMAIS la demande initiale, elle s'y ajoute : livrer le CHANGEMENT d'abord ou en parallèle, jamais l'échafaudage seul. Signal d'alerte à surveiller sur soi : quand le travail produit ne touche que `test/` alors que la demande porte sur `src/`, annoncer explicitement « je n'ai pas encore touché au correctif » à CHAQUE tour — et proposer de faire le fix, pas d'affiner le filet. [TRIGGERS keyword:garantie,zéro régression,échafaudage,filet,scope,dérive] + +- [2026-07-30 20:12] Après avoir régénéré le golden pour intégrer le fix, j'ai failli conclure « zéro régression » sur `bun test` vert + « le golden correspond au live » — TAUTOLOGIE : un golden régénéré correspond toujours au live, il ne prouve plus rien sur l'AVANT. Le fichier étant non committé, `git diff` ne montrait rien non plus. → Pour prouver quelles cellules ont bougé quand le témoin a déjà été régénéré : `git worktree add --detach /tmp/baseline HEAD`, y copier le harness de capture (non suivi), recapturer, diffé clé par clé. Résultat exploitable (« 2 cellules divergentes, toutes non-kimi identiques »), et `git worktree remove` derrière. Piège au passage : `cp -R src dst` quand `dst` existe copie DEDANS (`dst/src`) — vérifier l'arbre après copie. [TRIGGERS tool:Bash keyword:golden,régénéré,baseline,worktree,tautologie,non committé] + +- [2026-07-30 19:22] Conçu une capture golden en épinglant `process.env.HOME` vers un dossier de fixtures DANS le process de test. Faux : sous Bun 1.3.14 `os.homedir()` FIGE `$HOME` au démarrage du process (bug upstream oven-sh/bun#29244) — muter l'env ensuite ne change rien, et `homedir()` est appelé par 9 modules de `src/`. Le golden aurait capturé mon vrai `~/.claude/CLAUDE.md` et échoué chez tout le monde d'autre, en accusant le code. Vu SEULEMENT en mesurant (`bun -e` avant/après mutation + contre-témoin `HOME=x bun -e`), jamais par relecture. → Toute hypothèse sur le RUNTIME (cache d'une valeur d'env, indexation d'`argv`, `encoding` honoré ou Buffer silencieux) se MESURE en 30 s avec un contre-témoin ; la doc et le raisonnement ne tranchent pas. Et pour hermétiser : process ENFANT avec env en LISTE BLANCHE (`PATH`/`HOME`/… explicites), jamais `{...process.env, X}` — hériter fait fuiter `CLAUDECODE`, que `detectHarness()` lit, donc le golden diffère entre une session Claude Code et la CI. [TRIGGERS path:test/**/*.ts keyword:homedir,HOME,golden,fixture,hermétique,spawnSync,env,déterminisme] + +- [2026-07-30 19:22] Un test de caractérisation (golden JSON comparé par `toEqual`) est passé VERT du premier coup — et un vert de comparateur ne prouve RIEN : un comparateur vide, un golden vide ou un `runMatrix()` qui renvoie `{}` donnent le même vert permanent. Le seul contrôle qui vaut : MUTER une cellule du golden, exiger l'ÉCHEC, restaurer, vérifier le sha256 identique, réexiger le vert. → Un témoin de non-régression ne se livre jamais sans sa preuve de falsifiabilité. Corollaire committé : le golden va dans un commit SÉPARÉ et ANTÉRIEUR au changement de code — dans le même commit, « preuve » et « changement » sont indiscernables en relecture (même famille que l'assertion `toEqual` retouchée du 29/07). Et ne jamais utiliser `toMatchSnapshot()` pour ça : `--update-snapshots` le régénère, même en CI. [TRIGGERS path:test/**/*.ts keyword:golden,caractérisation,snapshot,toEqual,mutation,témoin,non-régression] + +- [2026-07-29 14:07] Pour RESTREINDRE une détection trop large (`.css$ || contenu-Tailwind` → « exiger le contenu »), le 1er jet a supprimé le test d'extension et gardé `contenu.test(content)` SEUL — ce qui ÉLARGISSAIT l'autre axe : n'importe quel `.tsx`/`.ts` contenant un `theme(...)` (styled-components, MUI, emotion, vanilla-extract l'utilisent tous) devenait `"tailwind"` et sautait la vraie détection de framework. Aucun test ne l'a vu : tous ne passaient que des chemins `.css`, donc l'axe élargi n'était jamais exercé. Rattrapé par le sniper en relecture statique, pas par le vert. → Restreindre une condition `A || B` en retirant `A` n'est PAS un rétrécissement : c'est un rétrécissement sur un axe et un ÉLARGISSEMENT sur l'autre. Garder les deux (`A && B`) et, surtout, écrire au moins un test qui exerce l'axe qu'on croit ne pas toucher — un jeu de tests qui ne varie qu'une dimension ne peut pas détecter l'élargissement de la seconde. Même famille que la traduction `prefix_rule` sur-large du 19/07. [TRIGGERS path:src/policy/*.ts keyword:detectFramework,restreindre,élargir,disjonction,axe non testé] + +- [2026-07-29 13:47] Après une hausse de quota, un exécuteur a rapporté qu'un test résistait et affirmé « no ordering or seeding trick reconciles it » — donc qu'il fallait modifier une assertion `toEqual` existante. FAUX : il avait placé la lecture manquante dans `setup()`, donc AVANT les lectures propres au test, ce qui polluait le tableau attendu. En lisant le test moi-même, la place évidente existait — après l'assertion, avant les captures — et ne touchait aucune assertion. → Une affirmation d'IMPOSSIBILITÉ venant d'un exécuteur se vérifie TOUJOURS sur le fichier, jamais ne s'accepte : c'est la forme d'affirmation qui fait passer un raccourci pour une fatalité (ici : retoucher une assertion). Et la règle qui tranche : quand un AJOUT de fixture obtient le même résultat qu'une assertion modifiée, l'ajout gagne toujours — une assertion retouchée est indistinguable, à la relecture, d'une assertion rognée pour faire passer du code. [TRIGGERS path:test/**/*.ts keyword:impossible,toEqual,assertion,fixture,setup,quota] + +- [2026-07-29 13:26] Diagnostiquant « le gate design ne compte rien », j'ai conclu que le pipeline était ABSENT du dist déployé : `grep -r "design-system.md" dist/` ne remontait rien. FAUX — le grep BSD (macOS) saute SILENCIEUSEMENT les fichiers à très longues lignes (chunk bundlé de 418 Ko, `file` le dit : « with very long lines ») ; avec `grep -a`, tout était là (`design-system.md` ×31, `currentPhase` ×10). J'allais livrer au proprio un diagnostic entièrement faux. Détecté seulement en testant le grep sur des chaînes TÉMOINS que je savais présentes (`PreToolUse`, `BLOCKED`) : elles ressortaient à 0. → Sur un artefact bundlé/minifié, toujours `grep -a`, et valider TOUT grep sur un témoin connu-présent AVANT d'en tirer une conclusion. Corollaire : ne jamais chercher un symbole par son NOM dans un bundle (la minification le renomme — `t as handleHook`) ; chercher des LITTÉRAUX de chaîne, qui survivent. [TRIGGERS tool:Bash keyword:grep,dist,bundle,minifié,absent,very long lines,témoin] + +- [2026-07-29 13:26] J'ai affirmé au proprio « une seule des onze références a un design-system.md » et j'en ai déduit qu'un correctif serait purement préparatoire. J'avais mesuré `Projets-clients/refs-design` (la copie SOURCE) alors que seul compte le corpus DÉPLOYÉ (`~/.claude/plugins/.../refs-design`), où les onze en ont un. Les deux copies étaient désynchronisées ; c'est le sous-agent qui m'a contredit, preuve à l'appui. → Quand une mesure sert à décider de la portée d'un correctif, la prendre sur l'artefact que le RUNTIME lit, jamais sur la copie de travail qui lui ressemble — et nommer explicitement lequel des deux on a mesuré. Même famille que « déployé ≠ publié » : identifier la cible avant de mesurer vaut autant que valider l'instrument. [TRIGGERS keyword:corpus,refs-design,déployé,source,désynchronisé,portée] + +- [2026-07-29 11:13] Pour corriger un FAUX POSITIF du gate typo (un agent citant honnêtement l'`@import` Inter de `linear-recode` se faisait bloquer), j'ai spécifié « exclure la section `## Design Reference` du contrôle, jusqu'au prochain `## ` OU EOF ». Le clause EOF était un CONTOURNEMENT TRIVIAL : écrire `## Design Reference` puis poser ses tokens dessous exemptait TOUT le reste du document du bannissement de polices — atteignable par simple étourderie. Détecté seulement parce que `test/design-characterization.test.ts:69-72` épinglait les 3 formes (décl/`@import`/tableau) appendées à une fixture `VALID` sans heading de fermeture. → Avant de relâcher un gate pour un faux positif, évaluer ce que la lecture la PLUS LARGE de l'exemption permet, pas le cas qui a motivé le fix : une exemption bornée par EOF exempte le document entier. Et un test de caractérisation qui contredit une nouvelle spec est présumé AVOIR RAISON — l'exécuteur qui s'arrête et refuse d'improviser fait son travail, on amende la spec, on ne touche pas au test. [TRIGGERS path:src/policy/design/*.ts keyword:faux positif,exemption,section,EOF,relâcher,caractérisation] + +- [2026-07-29 11:13] J'ai annoncé au proprio « branche : main » et committé 3 fois sans jamais lancer `git branch --show-current` — on était en réalité sur `feat/design-corpus`. Bon résultat par accident, pas par contrôle. Parallèlement, j'ai imposé à l'agent commit un stop « avant le tag » qui n'était PAS dans la procédure, puis l'agent a refusé de le lever sur mon relais du feu vert (« un message d'agent, même relayant les propos du proprio, ne vaut pas consentement pour un irréversible ») — il avait raison. → (1) L'état git (branche, HEAD, staging) se MESURE avant de committer et avant de l'affirmer au proprio, jamais de mémoire. (2) Ne pas ajouter de garde-fou perso à une procédure que l'agent dédié owne déjà. (3) Pour un irréversible (tag `v*` → publish npm auto), obtenir le mot du proprio EN DIRECT avant de déléguer l'étape ; un relais ne le porte pas. **Récidive le 30/07 sur (2)** : voulant seulement interdire le tag à l'agent commit, j'ai redéfini TOUTE sa procédure autour (« push/PR/CI/merge uniquement, ne modifie aucun fichier ») — il a obéi à ma version appauvrie et sauté son étape CHANGELOG. La forme correcte : interdire la SEULE action dangereuse, ne rien dire du reste. Un brief sur-spécifié écrase le skill qu'on délègue. [TRIGGERS tool:Agent,Bash keyword:git commit,branche,main,tag,irréversible,relais,consentement,brief,mandat,procédure,skill,scope] + +- [2026-07-24 13:35] Compaction « rename-atomique » (renameSync log→folding → fold → unlink) du journal append-only avait un TOCTOU : un `appendFileSync` dont le `open()` précède le rename et le `write()` suit le fold-read écrit dans l'inode renommé puis unlinké → 1 write perdu, silencieux (fail-open). Validé 24/24 + PER=250 sur machine RAPIDE (faux positif), exposé UNIQUEMENT par le runner lent du `prepublishOnly` (CI 2-vCPU) → publish npm avorté juste à temps. Le test STOCHASTIQUE (8×N writes, seuil absolu) donne des faux négatifs sur machine rapide. → (1) Une garantie de concurrence validée localement/machine rapide est une BORNE, pas une preuve ; le juge est le runtime le PLUS lent (prepublishOnly/CI 2-vCPU), jamais le local. (2) Garder un invariant de concurrence par une SONDE DÉTERMINISTE qui force l'entrelacement in-process (ou l'observable discriminant : sérialisation append↔compaction via timestamps) — indépendante de l'ordonnancement — PAS un test stochastique seul. (3) Ne jamais rename/unlink un fichier dans lequel un autre process écrit encore sans exclusion mutuelle : appends + compaction doivent partager le lock (append BLOQUANT jusqu'à acquisition, JAMAIS fail-open skip — sinon la race-loss devient skip-loss et les « write skipped » reviennent). [TRIGGERS path:src/tracking/*.ts keyword:TOCTOU,compaction,rename,appendFileSync,lost write,prepublishOnly] + +- [2026-07-24 13:35] Le proprio a dit « j'ai build+déployé le dist dans le marketplace » — FAUX : le binaire réellement exécuté par les hooks (`~/.claude/plugins/marketplaces/fusengine-plugins/plugins/node_modules/@fusengine/harness/dist/cli/bin.mjs`) était encore l'ancien 0.1.79 SANS le journal (build daté d'avant, version en retard). Mon propre check `grep … | head && echo NOUVEAU || echo ANCIEN` était bugué (le `head` masque l'échec du `grep` → affirmait NOUVEAU à tort). → (1) « J'ai déployé » ne se croit pas : vérifier le binaire RÉELLEMENT exécuté (grep d'un marqueur du fix + `stat` date + version du package.json installé) DANS le node_modules du marketplace, pas la parole ni le dist source. (2) `grep|head` en test booléen est un faux positif garanti — utiliser `if grep -rq … ; then` (exit code direct). (3) Tester en conditions réelles (déployer le dist local dans node_modules → fan-out d'agents réels + probe multi-process) AVANT le publish IRRÉVERSIBLE (tag `v*` → npm auto), jamais après. [TRIGGERS tool:Bash keyword:déployé,dist,bin.mjs,marketplace,grep head,npm publish] + +- [2026-07-23 21:18] Un test de concurrence (`concurrency 8×3 zero lost write`) passait en LOCAL (886/0, machine rapide) et sur le check PR, mais a CASSÉ le run CI post-merge sur main (runner 2-vCPU lent) : sous contention réelle le lock fail-open skip légitimement des writes (design F2.2 voulu), et le test assertait `length===24` exact → comptait un skip nommé comme une perte. J'ai lu « pass » sur `gh pr checks` (check de BRANCHE) sans voir que le run sur le SHA MERGÉ échouait → merge sur un test flaky. → (a) Avant de considérer une PR mergée-verte, vérifier le run CI sur le SHA MERGÉ (`gh api commits//check-runs`), pas seulement `gh pr checks` de la branche. (b) Un test de concurrence doit asserter l'invariant du DESIGN (aucune perte SILENCIEUSE : `landed+skipped===N`, aucune corruption : `Set.size===landed`), jamais un absolu (`===N`) que le fail-open voulu rend faux sur un runner lent. [TRIGGERS keyword:concurrency,flaky,CI,SHA mergé,fail-open,gh pr checks] + +- [2026-07-23 21:18] J'ai promis au proprio une garantie « shasum dist identique => régression impossible », puis le rebuild a produit un hash DIFFÉRENT alors que `src/` n'avait pas bougé — tsdown régénère des IDs de chunks, le build N'EST PAS reproductible bit-pour-bit. Garantie annoncée sans valider l'instrument (même classe que la leçon execpolicy). → La garantie dure d'absence de régression = `git diff -- src/` VIDE (source de prod byte-identique, lisible par git), PAS le shasum du binaire buildé (bundler non-déterministe). Ne jamais promettre une bit-identité de build sans avoir prouvé que le build est reproductible. [TRIGGERS keyword:shasum,dist,reproductible,tsdown,garantie,git diff src] + +- [2026-07-23 21:42] Fix#1 d'un test de concurrence flaky remontait le compteur de skips par STDOUT du sous-process (`console.log(n)` → `Number(out.trim())`) : vert en local, mais en CI (runner lent, contention totale → tous skippent) la capture stdout inter-process a remonté 0 au lieu de N → nouvel échec `Expected 24 Received 0`. Le sniper avait jugé cette capture stdout « robuste » — elle ne l'est pas sous CI. → Pour de l'IPC de RÉSULTAT dans un test multi-process, ne jamais dépendre de stdout (buffering/timing close-vs-data/sortie parasite du runtime) : écrire dans un FICHIER par worker (Bun.write/writeFileSync) lu après `close`. Et un fix de flakiness doit être PROUVÉ dans le cas extrême simulé (contention forcée où landed=0), pas seulement sur la machine locale rapide où le cas ne se produit jamais. [TRIGGERS keyword:stdout,IPC,sous-process,flaky,CI,fichier worker,capture] + +- [2026-07-23 21:59] En sondant un « lock qui perd des writes » j'ai lu `JSON.parse(readFileSync).refsRead` BRUT et vu landed=0 → j'ai failli conclure à un vrai bug de concurrence (perte silencieuse) et alarmer le proprio. Or le track est une ENVELOPPE signée `{data:"",...}` : le vrai contenu est dans `.data`, lu correctement par `loadTrack()` (ce que le test utilise) → landed=24, aucun bug. → Une sonde qui contredit un code prouvé par tests doit d'ABORD être suspectée elle-même : reproduire via la MÊME primitive que le code de prod (`loadTrack`, pas un `JSON.parse` brut), et imprimer le contenu réel du fichier avant de conclure. Même classe que la leçon execpolicy : valider l'instrument avant la mesure. [TRIGGERS keyword:sonde,loadTrack,enveloppe data,perte silencieuse,instrument,faux bug] + +- [2026-09-02 12:11] Un mandat détaillé (RED commands, « 25 fichiers modifiés », « version 0.1.90 ») décrivait un état ANTÉRIEUR à la PR #100 : arbre propre, 0.1.91 déjà publiée, les 3 RED déjà verts. Re-mesurer git status + version npm + chaque RED command AVANT le moindre brief a évité de relancer 7 « points restants » déjà faits. → Un mandat écrit n'est pas une mesure : rejouer ses commandes de preuve sur le HEAD courant d'abord, et re-baseliner le périmètre sur l'écart réel. [TRIGGERS keyword:mandat,RED,re-baseline,déjà fait,périmètre,prompt obsolète] + +- [2026-09-02 12:11] Payloads Cursor AUTHENTIQUES : Cursor journalise chaque exécution de hook (INPUT JSON complet + OUTPUT + diagnostics) dans `~/Library/Application Support/Cursor/logs/**/cursor.hooks*.log` ; le runtime réel des hooks est dans le worker `~/Library/Application Support/Cursor/User/globalStorage/anysphere.cursor-agent-worker/agent-cli/.local/share/cursor-agent/versions//{index.js,190.index.js}`, pas seulement dans Cursor.app. Piège : `find | xargs grep` casse sur l'espace de « Application Support » (résultat vide silencieux) → `-print0 | xargs -0` ou glob Python. [TRIGGERS keyword:cursor,hooks log,payload authentique,capture,agent-worker,Application Support,xargs] + +- [2026-09-02 12:18] Ajouter un NOUVEAU lecteur d'une variable d'env (`CLAUDE_PROJECT_DIR` dans `cursorProjectCwd`) a réveillé une fuite latente : `test/inject-context.test.ts` posait `process.env.CLAUDE_PROJECT_DIR = cwd` sans `finally`, invisible tant que personne ne lisait la variable ; `bun test` tourne en un seul process, donc un test Cursor ultérieur héritait d'un tmpdir mort → échec dépendant de l'ordre. → Avant d'introduire un lecteur de `process.env.X`, grepper `process.env.X =` dans test/ et exiger la restauration `finally` ; un test vert isolé mais rouge en suite complète = chercher d'abord une fuite d'env/état inter-fichiers. [TRIGGERS keyword:process.env,fuite,finally,ordre des tests,dépendant de l'ordre,bun test,single process] + +- [2026-09-02 13:33] Un sniper a contesté 2 champs de schéma Cursor (`additional_context` sur beforeSubmitPrompt/postToolUseFailure) parce que cursor.com/docs/hooks ne les liste pas ; le binaire 3.18.25 les LIT et les transmet (validateur + proto `additionalContext`). Même passe : la doc dit « invalid JSON sur beforeReadFile = allow through » alors que le binaire bloque (`permission_hook_invalid_output`). Et une prémisse binaire de première lecture était elle-même incomplète : la limite 10 000 de `additional_context` porte sur la CONCATÉNATION fusionnée de tous les hooks d'un même événement, et un hook `failClosed` sur l'étape transforme le dépassement en rejet de l'appel d'outil. → Pour un contrat d'hôte (Cursor), la doc publique est un indice, jamais une preuve : trancher par extrait du binaire, et pour chaque champ vérifier VALIDATEUR + CONSOMMATION + FUSION multi-hooks (pas seulement « le champ est accepté »). [TRIGGERS keyword:cursor,doc,binaire,validateur,additional_context,failClosed,fusion,carrier,contrat hôte] + +- [2026-09-02 14:08] Un échec intermittent (1 run sur 3, `confirm-codex-provenance.test.ts:124`) a failli être imputé aux changements Cursor en cours. Témoin HEAD (export `git archive` + 40 runs isolés) : 1/40 échecs sur le code VIERGE → préexistant. Cause : `DEV_VERBS` (`src/policy/claude-md-context.ts:13`) sans `\b` ; `add` est un mot 100 % hexadécimal et tombe au hasard dans le `randomUUID()` que le test met dans son prompt (0,6 % par prompt, 4 tirages/run ≈ 2,4 %/run). → Devant un flake pendant une modification : (1) reproduire sur un export HEAD AVANT toute attribution ; (2) tout test qui concatène un UUID/hex à un texte passé à une regex de mots doit utiliser un nonce sans lettres a-f, ou la regex doit porter `\b` ; (3) un mot-clé de regex composé uniquement de [a-f] (add, bad, cafe, dead, face, feed…) est un piège UUID. [TRIGGERS keyword:flaky,intermittent,randomUUID,hex,regex,\b,DEV_VERBS,add,témoin HEAD,git archive] + +- [2026-09-02 14:45] J'ai validé « `event === "BeforeMCPExecution"` suffit, ce littéral n'est produit que par le contrat Cursor » (grep -rn confirmé). Le challenger a montré qu'un harness non-Cursor forward son `hook_event_name` BRUT de stdin à ce même dispatcher : un payload claude-code/codex portant ce littéral entrait dans le gate (reproduit : 0 octet → deny). Garde par littéral = garde par donnée ; seule `id === "cursor" && …` est structurelle. → Quand la contrainte est « aucun changement hors X », chaque hunk dans un fichier partagé doit être gardé par l'IDENTITÉ de X, jamais par une valeur qu'un autre acteur pourrait émettre, même si personne ne l'émet aujourd'hui. [TRIGGERS keyword:garde,littéral,id === ,payload brut,dispatcher,hook_event_name,structurel,hors périmètre] + +- [2026-09-02 18:25] Un sniper transversal mandaté pour « sonde de concurrence à 5 processus + mesure sur 200 invocations » a tourné sans fin ; le propriétaire a dû tuer tous les agents et scripts (« ils sont infinis »). En parallèle, un nouveau module d'état (registre de budget) écrivait dans le VRAI ~/.fuse-harness/state à chaque run de test Cursor (54 dossiers de hash créés en une après-midi) parce que les tests isolaient le cwd mais pas $HOME. → (1) Tout brief d'exécutant/sniper fixe des bornes explicites : itérations ≤ N, `timeout` sur chaque spawn, pas de processus en arrière-plan ; une sonde de concurrence sans borne est interdite. (2) Tout nouveau writer d'état dérivé de `homedir()` impose, dans le même lot, l'isolation `HOME=` des tests qui l'atteignent (child env + `process.env.HOME` sauvegardé/restauré), et un test compte les fichiers créés sous le vrai home avant/après la suite. [TRIGGERS keyword:infini,boucle,sonde concurrence,timeout,spawn,HOME,homedir,état réel,fuse-harness/state,isolation tests] + +- [2026-09-02 18:52] Ma boucle « un `bun test ` par fichier, compte de fichiers avant/après » a rapporté 0 coupable partout : `timeout 120 bun test …` échouait silencieusement car `timeout`/`gtimeout` n'existent pas sur ce mac, donc AUCUN test n'a tourné. Le vrai coupable est sorti d'une autre méthode : un run complet + `find -newer marqueur` + lecture des clés dans les fichiers créés (session_id → grep dans test/). → Même leçon que le témoin positif : une boucle de diagnostic doit prouver qu'elle exécute réellement (afficher le compte de tests de chaque itération, ou un cas connu positif) ; et sur macOS ne jamais compter sur `timeout` sans vérifier `which`. Préférer l'attribution par artefact (contenu du fichier créé) à l'attribution par élimination. [TRIGGERS keyword:timeout,gtimeout,macOS,boucle morte,find -newer,attribution,témoin positif,bun test par fichier] + +- [2026-09-02 19:01] Le lot « isolation HOME des tests » a été livré « PASS » par l'exécutant ET son sniper, alors que la suite complète créait encore 5 fichiers par run dans le vrai home : tous deux n'avaient mesuré que les fichiers de test qu'ils venaient de toucher, pas la suite entière ; 4 autres fichiers de test appelaient le même chemin. → Une propriété globale (« aucun test n'écrit hors tmp ») se prouve par une mesure GLOBALE (compte avant/après sur `bun test` complet), jamais par la somme des fichiers modifiés ; l'exiger explicitement dans le brief ET la refaire soi-même avant tout « fait ». [TRIGGERS keyword:isolation,HOME,suite complète,avant/après,propriété globale,PASS partiel,fichiers touchés] diff --git a/src/adapters/cursor/context-budget.ts b/src/adapters/cursor/context-budget.ts new file mode 100644 index 0000000..b828514 --- /dev/null +++ b/src/adapters/cursor/context-budget.ts @@ -0,0 +1,144 @@ +/** + * @module context-budget + * Cursor-only shared `additional_context` budget registry. Cursor 3.18.25 + * runs every hook plugin configured on an event in its OWN process, then + * merges their `additional_context` outputs with a 9-char `"\n\n---\n\n"` + * separator and drops the WHOLE merge past 10,000 UTF-16 units — so no + * single process can know the total by itself. This module gives every + * plugin's process a shared, best-effort view of that total via a small + * JSON registry file under the project's state dir (see `../../runtime/paths.ts`), + * keyed by `${sessionId}|${event}|${generationId ?? ""}|${toolUseId ?? ""}` + * (one key per merge group — Cursor merges preToolUse/postToolUse/ + * postToolUseFailure PER TOOL CALL, so `toolUseId` joins the key on those + * three events), with entries older than 10s ignored (concurrent hooks on one + * event fire within the same second). Best-effort, fail-open throughout: any + * I/O or JSON error degrades to "no shared budget", i.e. the flat + * per-response cap in `./context-limit.ts` alone — never a thrown error, and + * never a Cursor-side regression. + */ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWrite } from "../../util/json-io"; +import { + ADDITIONAL_CONTEXT_LIMIT, TRUNCATION_MARKER, additionalContextLength, capAdditionalContext, omitAdditionalContext, +} from "./context-limit"; +import type { CursorBudgetContext } from "./interfaces/context-budget"; + +const REGISTRY_FILE = "cursor-context-budget.json"; +/** Matches Cursor 3.18.25's observed `"\n\n---\n\n"` merge separator length. */ +const SEPARATOR_LENGTH = 9; +const ENTRY_WINDOW_MS = 10_000; +/** Below this, a truncated value would carry more marker than budget — omit the field instead. */ +const OMIT_THRESHOLD = TRUNCATION_MARKER.length + 100; + +interface BudgetEntry { + at: number; + length: number; +} +type BudgetRegistry = Record; + +function isRegistry(value: unknown): value is BudgetRegistry { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function registryPath(stateDir: string): string { + return join(stateDir, REGISTRY_FILE); +} + +function budgetKey(ctx: Pick): string { + return `${ctx.sessionId}|${ctx.event}|${ctx.generationId ?? ""}|${ctx.toolUseId ?? ""}`; +} + +function loadRegistry(path: string): BudgetRegistry { + try { + if (!existsSync(path)) return {}; + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + return isRegistry(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function freshEntries(entries: BudgetEntry[] | undefined, now: number): BudgetEntry[] { + return (entries ?? []).filter((entry) => now - entry.at <= ENTRY_WINDOW_MS); +} + +/** Sum of a key's fresh entry lengths plus the separators already joining them. */ +function consumed(entries: BudgetEntry[]): number { + return entries.reduce((total, entry) => total + entry.length, 0) + SEPARATOR_LENGTH * Math.max(0, entries.length - 1); +} + +/** {@link reserveAdditionalContext} input: budget context plus the length the caller wants to emit. */ +export type ReserveInput = CursorBudgetContext & { wanted: number }; +/** {@link recordAdditionalContext} input: budget context plus the length actually emitted. */ +export type RecordInput = CursorBudgetContext & { emitted: number }; + +/** + * Reserve room in the shared budget for one hook's `additional_context` + * contribution to one (session, event, generation) merge group. `wanted` is + * accepted for a symmetric call shape with {@link recordAdditionalContext} + * but does not shrink `allowed` itself — the ceiling only depends on what + * OTHER entries already hold; a smaller `wanted` simply means the caller + * won't need all of it. Best-effort, fail-open: any I/O/JSON error returns + * the full flat ceiling, as if no other plugin had run. + * @param input - Registry location, reservation key, and the wanted length. + */ +export function reserveAdditionalContext(input: ReserveInput): { allowed: number } { + try { + const now = input.now ?? Date.now(); + const registry = loadRegistry(registryPath(input.stateDir)); + const fresh = freshEntries(registry[budgetKey(input)], now); + const separator = fresh.length > 0 ? SEPARATOR_LENGTH : 0; + return { allowed: Math.max(0, ADDITIONAL_CONTEXT_LIMIT - consumed(fresh) - separator) }; + } catch { + return { allowed: ADDITIONAL_CONTEXT_LIMIT }; + } +} + +/** + * Record the length actually emitted for one reservation, best-effort. + * Prunes every key's stale entries while it holds the write so the registry + * file stays bounded. Silently no-ops on any I/O error (fail-open). + * @param input - Registry location, reservation key, and the emitted length. + */ +export function recordAdditionalContext(input: RecordInput): void { + try { + const now = input.now ?? Date.now(); + const path = registryPath(input.stateDir); + const registry = loadRegistry(path); + const pruned: BudgetRegistry = {}; + for (const [key, entries] of Object.entries(registry)) { + const fresh = freshEntries(entries, now); + if (fresh.length > 0) pruned[key] = fresh; + } + const key = budgetKey(input); + pruned[key] = [...(pruned[key] ?? []), { at: now, length: input.emitted }]; + atomicWrite(path, JSON.stringify(pruned)); + } catch { + // Best-effort: a lost entry only makes the NEXT reservation over-generous + // (never under), which is the safe direction to fail in. + } +} + +/** + * Cap a Cursor stdout JSON's `additional_context` against the shared budget + * instead of the flat per-response ceiling alone. Falls back to the plain + * cap (`./context-limit.ts`), unbudgeted, when `budget` is `undefined` or + * the stdout carries no `additional_context` at all. + * @param stdout - A native Cursor JSON stdout candidate. + * @param budget - Shared budget context, or `undefined` to skip it. + */ +export function capAdditionalContextWithBudget(stdout: string, budget: CursorBudgetContext | undefined): string { + if (!budget) return capAdditionalContext(stdout); + const wanted = additionalContextLength(stdout); + if (wanted === 0) return stdout; + const { allowed } = reserveAdditionalContext({ ...budget, wanted }); + if (allowed < OMIT_THRESHOLD) { + process.stderr.write(`[fuse-harness] cursor: additional_context budget exhausted for ${budget.event} (allowed=${allowed})\n`); + return omitAdditionalContext(stdout); + } + const limit = Math.min(ADDITIONAL_CONTEXT_LIMIT, allowed); + const capped = capAdditionalContext(stdout, limit); + recordAdditionalContext({ ...budget, emitted: additionalContextLength(capped) }); + return capped; +} diff --git a/src/adapters/cursor/context-limit.ts b/src/adapters/cursor/context-limit.ts new file mode 100644 index 0000000..e80902d --- /dev/null +++ b/src/adapters/cursor/context-limit.ts @@ -0,0 +1,115 @@ +/** + * @module context-limit + * Cursor 3.18.25's `hooks-carriers` drops an `additional_context` carrier + * once `o.length>1e4` — but `o` is the MERGED text of every hook's + * `additional_context` for that event (concatenated with `"\n\n---\n\n"` + * before the 10,000-char check), not this harness's response in isolation. + * Capping our own contribution at {@link ADDITIONAL_CONTEXT_LIMIT} is + * therefore the LAST-RESORT guard, not the real protection: on its own it + * only proves OUR piece stays under 10,000, while the total across every + * hook plugin configured on the same event can still exceed it and get + * dropped wholesale — measured at ~8,400 chars on `sessionStart` from core + * plugins alone, close enough to the ceiling that one more plugin tips it + * over. The actual protection is the cross-process shared budget registry + * in `./context-budget.ts` (Cursor id only), which reserves a slice of the + * 10,000 ceiling per (session, event, generation) key BEFORE calling + * {@link truncateAdditionalContext} here with the reserved amount instead of + * the flat {@link ADDITIONAL_CONTEXT_LIMIT} — this module stays a pure, + * budget-agnostic primitive so it keeps working unbudgeted (its historical, + * still-correct behavior) wherever no budget context is available. The + * limit unit is UTF-16 code units (`String.prototype.length`), matching + * `value.length` here exactly. Only 5 events carry `additional_context` + * through this carrier — sessionStart, beforeSubmitPrompt, preToolUse, + * postToolUse, postToolUseFailure — subagentStart/subagentStop use a + * different, unlimited channel. "Drops silently" also only holds when no + * `failClosed: true` hook is declared on that step/tool: with one declared, + * an oversized carrier REJECTS the tool call instead of being dropped quiet. + */ + +/** Cursor's hard `additional_context` character ceiling. */ +export const ADDITIONAL_CONTEXT_LIMIT = 10_000; + +/** Suffix appended by {@link truncateAdditionalContext} once a value is cut. */ +export const TRUNCATION_MARKER = "\n[fuse-harness] additional_context truncated to Cursor's 10000-char limit"; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Truncate a string to end with {@link TRUNCATION_MARKER} once its length + * exceeds `limit`. Leaves shorter values untouched. Idempotent under a + * SHRINKING `limit` across repeated calls (e.g. an unbudgeted flat-cap pass + * followed by a budgeted re-cap of the same stdout — see `./respond.ts`'s + * `toCursorLifecycleResponse` doc): when `value` already ends with + * {@link TRUNCATION_MARKER}, that marker is stripped BEFORE re-slicing so the + * result carries exactly one marker instead of risking a duplicated/cut one. + * @param value - Candidate `additional_context` body. + * @param limit - Effective ceiling for this call (defaults to the flat + * {@link ADDITIONAL_CONTEXT_LIMIT}; a shared-budget caller passes a smaller, + * per-reservation value instead). + */ +export function truncateAdditionalContext(value: string, limit: number = ADDITIONAL_CONTEXT_LIMIT): string { + const alreadyMarked = value.endsWith(TRUNCATION_MARKER); + if (!alreadyMarked && value.length <= limit) return value; + if (limit <= TRUNCATION_MARKER.length) return TRUNCATION_MARKER.slice(0, Math.max(0, limit)); + const base = alreadyMarked ? value.slice(0, value.length - TRUNCATION_MARKER.length) : value; + return base.slice(0, limit - TRUNCATION_MARKER.length) + TRUNCATION_MARKER; +} + +/** + * Length of a Cursor stdout JSON's `additional_context` string field, or 0 + * when the stdout is not JSON, has no such field, or that field isn't a + * string. + * @param stdout - A native Cursor JSON stdout candidate. + */ +export function additionalContextLength(stdout: string): number { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return 0; + } + return isPlainObject(parsed) && typeof parsed.additional_context === "string" ? parsed.additional_context.length : 0; +} + +/** + * Re-serialize a Cursor stdout string with its `additional_context` field + * dropped entirely — used once the shared budget has no room left even for + * a truncated marker. Returns the input byte-for-byte unchanged when it is + * not JSON or has no string `additional_context` field. + * @param stdout - A native Cursor JSON stdout candidate. + */ +export function omitAdditionalContext(stdout: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return stdout; + } + if (!isPlainObject(parsed) || typeof parsed.additional_context !== "string") return stdout; + const { additional_context: _omitted, ...rest } = parsed; + return JSON.stringify(rest); +} + +/** + * Re-serialize a Cursor stdout string with its `additional_context` field + * capped at `limit` characters. Returns the input byte-for-byte unchanged + * when it is not JSON, has no string `additional_context` field, or that + * field is already within the limit — so callers can wrap every return path + * unconditionally. + * @param stdout - A native Cursor JSON stdout candidate. + * @param limit - Effective ceiling for this call (see {@link truncateAdditionalContext}). + */ +export function capAdditionalContext(stdout: string, limit: number = ADDITIONAL_CONTEXT_LIMIT): string { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return stdout; + } + if (!isPlainObject(parsed) || typeof parsed.additional_context !== "string") return stdout; + const truncated = truncateAdditionalContext(parsed.additional_context, limit); + if (truncated === parsed.additional_context) return stdout; + return JSON.stringify({ ...parsed, additional_context: truncated }); +} diff --git a/src/adapters/cursor/context.ts b/src/adapters/cursor/context.ts index 49abb97..2ec6794 100644 --- a/src/adapters/cursor/context.ts +++ b/src/adapters/cursor/context.ts @@ -30,17 +30,36 @@ function contains(root: string, filePath: string): boolean { return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); } -/** Select Cursor's project scope without replacing a valid payload cwd. */ +/** + * Select Cursor's project scope without replacing a valid payload cwd. Order: + * payload `cwd` -> longest workspace root containing `filePath` -> + * `workspaceRoots[0]` -> `CURSOR_PROJECT_DIR` env -> `CLAUDE_PROJECT_DIR` env + * -> `fallback`. Both env vars are validated the same way as any other Cursor + * path (`cursorAbsolutePath`: absolute, NUL-free, realpath-resolved), so an + * unset or malformed value is silently skipped rather than trusted. + * @param cwd - Cursor payload `cwd`, when present. + * @param workspaceRoots - Validated, deduped Cursor `workspace_roots`. + * @param filePath - The file the current event targets, when present. + * @param fallback - Caller-supplied last resort (never `process.cwd()`). + * @param env - Environment (defaults to `process.env`). + * @returns The resolved project root. + */ export function cursorProjectCwd( cwd: string | undefined, workspaceRoots: readonly string[], filePath: string | undefined, fallback: string, + env: Record = process.env, ): string { if (cwd) return cwd; if (filePath) { const matches = workspaceRoots.filter((root) => contains(root, filePath)); if (matches.length > 0) return matches.sort((a, b) => b.length - a.length)[0]!; } - return workspaceRoots[0] ?? fallback; + if (workspaceRoots[0]) return workspaceRoots[0]; + const fromCursorEnv = cursorAbsolutePath(env.CURSOR_PROJECT_DIR); + if (fromCursorEnv) return fromCursorEnv; + const fromClaudeEnv = cursorAbsolutePath(env.CLAUDE_PROJECT_DIR); + if (fromClaudeEnv) return fromClaudeEnv; + return fallback; } diff --git a/src/adapters/cursor/interfaces/context-budget.ts b/src/adapters/cursor/interfaces/context-budget.ts new file mode 100644 index 0000000..3eb984e --- /dev/null +++ b/src/adapters/cursor/interfaces/context-budget.ts @@ -0,0 +1,25 @@ +/** + * Reservation key + registry location for one hook invocation's slice of + * Cursor's shared, cross-process `additional_context` budget (see + * `../context-budget.ts`). `undefined` at a call site means "no shared + * budget available" — callers then fall back to the flat per-response cap. + */ +export interface CursorBudgetContext { + /** Project state directory the registry file lives under (see `defaultStateDir`). */ + stateDir: string; + /** Cursor `session_id` (its `conversation_id`). */ + sessionId: string; + /** Raw Cursor `hook_event_name` (e.g. `"sessionStart"`). */ + event: string; + /** Cursor `generation_id`; absent on `sessionStart`/`workspaceOpen`. */ + generationId?: string; + /** + * Cursor `tool_use_id`; present on preToolUse/postToolUse/postToolUseFailure + * — Cursor merges `additional_context` PER TOOL CALL for these events, not + * once per (session, event, generation), so this must join the key or + * concurrent tool calls in the same generation would wrongly share one slice. + */ + toolUseId?: string; + /** Test seam: injectable clock (defaults to `Date.now()`). */ + now?: number; +} diff --git a/src/adapters/cursor/native-response.ts b/src/adapters/cursor/native-response.ts index 0db5ecc..129ecee 100644 --- a/src/adapters/cursor/native-response.ts +++ b/src/adapters/cursor/native-response.ts @@ -1,132 +1,13 @@ -type FieldValidator = (value: unknown) => boolean; - -interface NativeSchema { - fields: Readonly>; - required?: readonly string[]; -} - -const stringValue: FieldValidator = (value) => typeof value === "string"; -const booleanValue: FieldValidator = (value) => typeof value === "boolean"; -const plainRecord = (value: unknown): value is Record => { - if (typeof value !== "object" || value === null || Array.isArray(value)) return false; - try { - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; - } catch { - return false; - } -}; - -type JsonFrame = { value: unknown; leave?: false } | { value: object; leave: true }; - -function jsonChildren(value: object): unknown[] | null { - const keys = Reflect.ownKeys(value); - const descriptors = Object.getOwnPropertyDescriptors(value); - if (Array.isArray(value)) { - if (Object.getPrototypeOf(value) !== Array.prototype) return null; - const length = descriptors.length; - if (!length || !("value" in length) || !Number.isSafeInteger(length.value) || length.value < 0) return null; - if (keys.length !== length.value + 1 || keys.some((key) => typeof key === "symbol")) return null; - const children: unknown[] = []; - for (let index = 0; index < length.value; index += 1) { - const descriptor = descriptors[String(index)]; - if (!descriptor?.enumerable || !("value" in descriptor)) return null; - children.push(descriptor.value); - } - return children; - } - if (!plainRecord(value) || keys.some((key) => typeof key === "symbol")) return null; - const children: unknown[] = []; - for (const key of keys) { - const descriptor = descriptors[key as string]; - if (!descriptor?.enumerable || !("value" in descriptor)) return null; - children.push(descriptor.value); - } - return children; -} - -function jsonValue(root: unknown): boolean { - const active = new WeakSet(); - const stack: JsonFrame[] = [{ value: root }]; - while (stack.length > 0) { - const frame = stack.pop()!; - if (frame.leave) { - active.delete(frame.value); - continue; - } - const { value } = frame; - if (value === null || typeof value === "string" || typeof value === "boolean") continue; - if (typeof value === "number") { - if (!Number.isFinite(value)) return false; - continue; - } - if (typeof value !== "object" || active.has(value)) return false; - let children: unknown[] | null; - try { - children = jsonChildren(value); - } catch { - return false; - } - if (!children) return false; - active.add(value); - stack.push({ value, leave: true }); - for (let index = children.length - 1; index >= 0; index -= 1) stack.push({ value: children[index] }); - } - return true; -} - -const recordValue: FieldValidator = (value) => plainRecord(value) && jsonValue(value); -const stringRecord: FieldValidator = (value) => { - if (!recordValue(value)) return false; - try { - return Object.values(Object.getOwnPropertyDescriptors(value as object)) - .every((descriptor) => "value" in descriptor && typeof descriptor.value === "string"); - } catch { - return false; - } -}; -const stringArray: FieldValidator = (value) => Array.isArray(value) && value.every(stringValue); -const permission = (...values: string[]): FieldValidator => (value) => typeof value === "string" && values.includes(value); - -const EMPTY: NativeSchema = { fields: {} }; -const FOLLOWUP: NativeSchema = { fields: { followup_message: stringValue } }; -const PERMISSION_ASK: NativeSchema = { - fields: { permission: permission("allow", "deny", "ask"), user_message: stringValue, agent_message: stringValue }, - required: ["permission"], -}; - -const NATIVE_SCHEMAS = { - sessionStart: { - fields: { env: stringRecord, additional_context: stringValue, continue: booleanValue, user_message: stringValue }, - }, - sessionEnd: EMPTY, - beforeSubmitPrompt: { fields: { continue: booleanValue, user_message: stringValue }, required: ["continue"] }, - preCompact: { fields: { user_message: stringValue } }, - subagentStart: { - fields: { permission: permission("allow", "deny"), user_message: stringValue }, required: ["permission"], - }, - subagentStop: FOLLOWUP, - preToolUse: { - fields: { ...PERMISSION_ASK.fields, updated_input: recordValue }, required: ["permission"], - }, - postToolUse: { fields: { updated_mcp_tool_output: recordValue, additional_context: stringValue } }, - postToolUseFailure: EMPTY, - beforeShellExecution: PERMISSION_ASK, - afterShellExecution: EMPTY, - beforeMCPExecution: PERMISSION_ASK, - afterMCPExecution: EMPTY, - beforeReadFile: { - fields: { permission: permission("allow", "deny"), user_message: stringValue }, required: ["permission"], - }, - afterFileEdit: EMPTY, - beforeTabFileRead: { fields: { permission: permission("allow", "deny") }, required: ["permission"] }, - afterTabFileEdit: EMPTY, - afterAgentResponse: EMPTY, - afterAgentThought: EMPTY, - stop: FOLLOWUP, - workspaceOpen: { fields: { pluginPaths: stringArray } }, -} as const satisfies Record; - +import { NATIVE_SCHEMAS, recordValue, type NativeSchema } from "./native-schemas"; + +/** + * Check that every enumerable own key of `value` is a documented field for + * `eventName` and passes its validator, and that every required field is + * present. Rejects prototype-polluted or exotic-shaped candidates via + * {@link recordValue}. + * @param value - Parsed JSON candidate. + * @param eventName - The Cursor hook event the candidate would answer. + */ function isNativeCursorResponse(value: unknown, eventName: string): boolean { try { if (!recordValue(value)) return false; diff --git a/src/adapters/cursor/native-schemas.ts b/src/adapters/cursor/native-schemas.ts new file mode 100644 index 0000000..80a91dc --- /dev/null +++ b/src/adapters/cursor/native-schemas.ts @@ -0,0 +1,161 @@ +/** + * @module native-schemas + * Per-event field validators for Cursor's documented native stdout contract. + * Extracted from native-response.ts to keep that module focused on the + * passthrough decision logic (SOLID file-size split, not a plafond workaround). + * + * Field lists are binary-verified against Cursor 3.18.25 (agent-cli + * `190.index.js` / `workbench.desktop.main.js`, validators `R`/`Ded`) and + * match the published hooks documentation. + */ + +/** A single-field runtime type check used to build a {@link NativeSchema}. */ +export type FieldValidator = (value: unknown) => boolean; + +/** The exact field set (and per-field validator) Cursor reads for one event. */ +export interface NativeSchema { + fields: Readonly>; + required?: readonly string[]; +} + +const stringValue: FieldValidator = (value) => typeof value === "string"; +const booleanValue: FieldValidator = (value) => typeof value === "boolean"; + +/** A plain `{}`-literal or `Object.create(null)` object — never a class instance or array. */ +export const plainRecord = (value: unknown): value is Record => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + try { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; + } catch { + return false; + } +}; + +type JsonFrame = { value: unknown; leave?: false } | { value: object; leave: true }; + +function jsonChildren(value: object): unknown[] | null { + const keys = Reflect.ownKeys(value); + const descriptors = Object.getOwnPropertyDescriptors(value); + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) return null; + const length = descriptors.length; + if (!length || !("value" in length) || !Number.isSafeInteger(length.value) || length.value < 0) return null; + if (keys.length !== length.value + 1 || keys.some((key) => typeof key === "symbol")) return null; + const children: unknown[] = []; + for (let index = 0; index < length.value; index += 1) { + const descriptor = descriptors[String(index)]; + if (!descriptor?.enumerable || !("value" in descriptor)) return null; + children.push(descriptor.value); + } + return children; + } + if (!plainRecord(value) || keys.some((key) => typeof key === "symbol")) return null; + const children: unknown[] = []; + for (const key of keys) { + const descriptor = descriptors[key as string]; + if (!descriptor?.enumerable || !("value" in descriptor)) return null; + children.push(descriptor.value); + } + return children; +} + +function jsonValue(root: unknown): boolean { + const active = new WeakSet(); + const stack: JsonFrame[] = [{ value: root }]; + while (stack.length > 0) { + const frame = stack.pop()!; + if (frame.leave) { + active.delete(frame.value); + continue; + } + const { value } = frame; + if (value === null || typeof value === "string" || typeof value === "boolean") continue; + if (typeof value === "number") { + if (!Number.isFinite(value)) return false; + continue; + } + if (typeof value !== "object" || active.has(value)) return false; + let children: unknown[] | null; + try { + children = jsonChildren(value); + } catch { + return false; + } + if (!children) return false; + active.add(value); + stack.push({ value, leave: true }); + for (let index = children.length - 1; index >= 0; index -= 1) stack.push({ value: children[index] }); + } + return true; +} + +/** A JSON-safe plain object (no cycles, no non-finite numbers, no exotic prototypes). */ +export const recordValue: FieldValidator = (value) => plainRecord(value) && jsonValue(value); +const stringRecord: FieldValidator = (value) => { + if (!recordValue(value)) return false; + try { + return Object.values(Object.getOwnPropertyDescriptors(value as object)) + .every((descriptor) => "value" in descriptor && typeof descriptor.value === "string"); + } catch { + return false; + } +}; +const stringArray: FieldValidator = (value) => Array.isArray(value) && value.every(stringValue); +const permission = (...values: string[]): FieldValidator => (value) => typeof value === "string" && values.includes(value); + +const EMPTY: NativeSchema = { fields: {} }; +const FOLLOWUP: NativeSchema = { fields: { followup_message: stringValue } }; +const PERMISSION_ASK: NativeSchema = { + fields: { permission: permission("allow", "deny", "ask"), user_message: stringValue, agent_message: stringValue }, + required: ["permission"], +}; + +const PRE_TOOL_USE: NativeSchema = { + fields: { + permission: permission("allow", "deny", "ask"), + user_message: stringValue, + agent_message: stringValue, + updated_input: recordValue, + additional_context: stringValue, + }, + required: ["permission"], +}; + +/** + * Exact native stdout field set Cursor 3.18.25 reads per hook event. + * Nothing beyond this list is invented: any additional key on a candidate + * value fails {@link isNativeCursorResponse} in native-response.ts. + */ +export const NATIVE_SCHEMAS: Readonly> = { + sessionStart: { + fields: { env: stringRecord, additional_context: stringValue, continue: booleanValue, user_message: stringValue }, + }, + sessionEnd: EMPTY, + beforeSubmitPrompt: { + fields: { continue: booleanValue, user_message: stringValue, additional_context: stringValue }, + required: ["continue"], + }, + preCompact: { fields: { user_message: stringValue } }, + subagentStart: { + fields: { permission: permission("allow", "deny"), user_message: stringValue }, required: ["permission"], + }, + subagentStop: FOLLOWUP, + preToolUse: PRE_TOOL_USE, + postToolUse: { fields: { updated_mcp_tool_output: recordValue, additional_context: stringValue } }, + postToolUseFailure: { fields: { additional_context: stringValue } }, + beforeShellExecution: PERMISSION_ASK, + afterShellExecution: EMPTY, + beforeMCPExecution: PERMISSION_ASK, + afterMCPExecution: EMPTY, + beforeReadFile: { + fields: { permission: permission("allow", "deny"), user_message: stringValue }, required: ["permission"], + }, + afterFileEdit: EMPTY, + beforeTabFileRead: { fields: { permission: permission("allow", "deny") }, required: ["permission"] }, + afterTabFileEdit: EMPTY, + afterAgentResponse: EMPTY, + afterAgentThought: EMPTY, + stop: FOLLOWUP, + workspaceOpen: { fields: { pluginPaths: stringArray } }, +}; diff --git a/src/adapters/cursor/normalize.ts b/src/adapters/cursor/normalize.ts index 8e0618e..48407c8 100644 --- a/src/adapters/cursor/normalize.ts +++ b/src/adapters/cursor/normalize.ts @@ -49,12 +49,77 @@ function sanitizedCursorInput(input: Record): Record` tool_name form on preToolUse/ + * postToolUse/postToolUseFailure LOSES the MCP server name (ground truth: + * Cursor CLI 3.18.25 + official docs — only beforeMCPExecution/ + * afterMCPExecution carry `mcp_server_name`). This reconstructs the real + * server for the closed set of tool names this repo's gates actually depend + * on (GATED_TOOLS in doc-cache-gate.ts, CONTEXT7_SOURCE, RESEARCH_TOOLS, + * SHOT_TOOLS, gemini-mcp-gate, shadcn-skill-gate) — same closed-table + * philosophy as `mcp-tool-name.ts`'s Codex aliasing, never a blanket + * reversal. Coordinator decision: a tool name OUTSIDE this table (server + * genuinely unrecoverable, and no safe placeholder) is left as Cursor's raw + * `MCP:` string — `test/cursor-followup-normalize.test.ts` pins this + * as the committed contract ("commandless MCP tools keep their name"), so a + * fabricated `mcp__cursor__` placeholder is never introduced for the + * unknown case. + */ +const CURSOR_MCP_TOOL_SERVERS: Readonly> = Object.assign(Object.create(null), { + "query-docs": "context7", + "resolve-library-id": "context7", + web_search_exa: "exa", + get_code_context_exa: "exa", + deep_researcher_start: "exa", + deep_researcher_check: "exa", + create_frontend: "gemini-design", + modify_frontend: "gemini-design", + snippet_frontend: "gemini-design", + search_items_in_registries: "shadcn", + view_items_in_registries: "shadcn", + get_item_examples_from_registries: "shadcn", + get_add_command_for_items: "shadcn", + get_audit_checklist: "shadcn", +}); + +/** + * The real MCP server for a bare Cursor tool name (the part after `MCP:`), + * or `undefined` when it isn't in the closed table. fuse-browser is inferred + * from the `browser_*` prefix — every fuse-browser tool is named that way + * and no other server in this ecosystem uses it — the remaining, + * non-distinctive tool names go through {@link CURSOR_MCP_TOOL_SERVERS}. + * NO placeholder fallback (coordinator decision, see {@link CURSOR_MCP_TOOL_SERVERS}): + * an unknown tool name means the server is genuinely unrecoverable, so the + * caller leaves the raw `MCP:` string untouched instead of fabricating one. + */ +function cursorMcpServer(bareTool: string): string | undefined { + if (bareTool.startsWith("browser_")) return "fuse-browser"; + return CURSOR_MCP_TOOL_SERVERS[bareTool]; +} + +/** + * Canonicalize Cursor's `MCP:` tool_name (preToolUse/postToolUse/ + * postToolUseFailure) into the shared `mcp____` shape every + * other harness/gate expects. Returns `undefined` — meaning "leave the raw + * `MCP:` string as-is" — both when `tool` isn't the `MCP:` form and + * when the bare tool name is outside the closed {@link CURSOR_MCP_TOOL_SERVERS} + * table (server unrecoverable, no placeholder fabricated). + */ +function cursorBareMcpToolName(tool: string | undefined): string | undefined { + if (!tool || !tool.startsWith("MCP:")) return undefined; + const bare = tool.slice(4); + const server = cursorMcpServer(bare); + return server ? `mcp__${server}__${bare}` : undefined; +} + function cursorToolName(raw: Record, event: string, tool: string | undefined, hasCommand: boolean): string { if (hasCommand) return "Bash"; const server = str(raw.mcp_server_name)?.trim().replace(/[^A-Za-z0-9_-]+/g, "_"); if (/^(before|after)MCPExecution$/i.test(event) && server && tool && !tool.startsWith("mcp__")) { return `mcp__${server}__${tool}`; } + const bareMcp = cursorBareMcpToolName(tool); + if (bareMcp) return bareMcp; if (tool === "Write") return "Edit"; return tool ?? ""; } diff --git a/src/adapters/cursor/plugin-root.ts b/src/adapters/cursor/plugin-root.ts new file mode 100644 index 0000000..56f7740 --- /dev/null +++ b/src/adapters/cursor/plugin-root.ts @@ -0,0 +1,103 @@ +/** + * Cursor plugin-root resolution — independent from the rules-plugin probing + * in `../../runtime/lifecycle/rules-root.ts`. Ground truth (Cursor 3.18.25 + * binary + cursor.com/docs/hooks): `CURSOR_PLUGIN_ROOT` / `CLAUDE_PLUGIN_ROOT` + * (both equal to the plugin install dir) are injected ONLY into + * plugin-declared hook processes — never user (`~/.cursor/hooks.json`), + * project (`.cursor/hooks.json`), or enterprise hooks. A plugin hook's cwd is + * the plugin install dir, EXCEPT for `stop`/`subagentStop`, where it is the + * workspace root — callers must pass the right `cwd` for the event they are + * handling. Precedence: (1) `CURSOR_PLUGIN_ROOT` env, (2) `CLAUDE_PLUGIN_ROOT` + * env, (3) `cwd` when it carries a Cursor plugin marker + * (`.cursor-plugin/plugin.json`, `plugin.json` + `hooks/hooks.json`, or a + * bare `hooks/hooks.json` — matches installed-plugin layouts under + * `~/.cursor/plugins/cache/**` and `~/.cursor/plugins/local//`), (4) + * `none`. Cursor refuses symlinked config paths itself; we do not share that + * constraint, so every resolved candidate is realpath-followed instead. + */ +import { existsSync, realpathSync, statSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; + +/** How the resolved Cursor plugin root was determined. */ +export type CursorPluginRootSource = + | "env:CURSOR_PLUGIN_ROOT" + | "env:CLAUDE_PLUGIN_ROOT" + | "cwd:plugin-marker" + | "none"; + +/** Result of resolving the Cursor plugin install root. */ +export interface CursorPluginRootResult { + /** Realpath-resolved plugin install directory, or `null` when unproven. */ + root: string | null; + /** Which precedence step produced `root`. */ + source: CursorPluginRootSource; + /** One diagnostic entry per candidate that was examined and rejected. */ + checked: string[]; +} + +/** Validate an env candidate: non-empty, NUL-free, absolute, existing dir. */ +function validateEnvCandidate(label: string, value: string | undefined, checked: string[]): string | null { + if (value === undefined || value === "") { + checked.push(`${label}: unset`); + return null; + } + if (value.includes("\0")) { + checked.push(`${label}: invalid (contains NUL): "${value}"`); + return null; + } + if (!isAbsolute(value)) { + checked.push(`${label}: invalid (not absolute): "${value}"`); + return null; + } + try { + if (!statSync(value).isDirectory()) { + checked.push(`${label}: invalid (not a directory): "${value}"`); + return null; + } + } catch { + checked.push(`${label}: invalid (no such directory): "${value}"`); + return null; + } + try { + return realpathSync.native(value); + } catch { + checked.push(`${label}: invalid (realpath failed): "${value}"`); + return null; + } +} + +/** True when `dir` carries a recognized Cursor plugin install marker. */ +function hasPluginMarker(dir: string): boolean { + if (existsSync(join(dir, ".cursor-plugin", "plugin.json"))) return true; + if (existsSync(join(dir, "plugin.json")) && existsSync(join(dir, "hooks", "hooks.json"))) return true; + return existsSync(join(dir, "hooks", "hooks.json")); +} + +/** + * Resolve the Cursor plugin install root a plugin-declared hook runs from. + * @param env - Environment (defaults to `process.env`). + * @param cwd - The hook process's cwd for the current event (plugin root for + * most events, workspace root for `stop`/`subagentStop` — caller's choice). + * @returns The resolved root, its source, and every rejected candidate. + */ +export function resolveCursorPluginRoot( + env: Record, + cwd: string, +): CursorPluginRootResult { + const checked: string[] = []; + const fromCursor = validateEnvCandidate("env:CURSOR_PLUGIN_ROOT", env.CURSOR_PLUGIN_ROOT, checked); + if (fromCursor) return { root: fromCursor, source: "env:CURSOR_PLUGIN_ROOT", checked }; + const fromClaude = validateEnvCandidate("env:CLAUDE_PLUGIN_ROOT", env.CLAUDE_PLUGIN_ROOT, checked); + if (fromClaude) return { root: fromClaude, source: "env:CLAUDE_PLUGIN_ROOT", checked }; + if (hasPluginMarker(cwd)) { + let resolved = cwd; + try { + resolved = realpathSync.native(cwd); + } catch { + /* keep raw cwd when realpath fails (e.g. already-canonical or unreadable parent) */ + } + return { root: resolved, source: "cwd:plugin-marker", checked }; + } + checked.push(`cwd:"${cwd}": no plugin marker found`); + return { root: null, source: "none", checked }; +} diff --git a/src/adapters/cursor/respond.ts b/src/adapters/cursor/respond.ts index bcb35d1..99d23c6 100644 --- a/src/adapters/cursor/respond.ts +++ b/src/adapters/cursor/respond.ts @@ -1,6 +1,9 @@ import { formatPrompt, type Prompt } from "../../prompt/types"; import { cursorEventContract } from "./events"; import { parseNativeCursorStdout } from "./native-response"; +import { capAdditionalContext } from "./context-limit"; +import { capAdditionalContextWithBudget } from "./context-budget"; +import type { CursorBudgetContext } from "./interfaces/context-budget"; const AGENT_MESSAGE_EVENTS = new Set([ "preToolUse", @@ -30,39 +33,73 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** Render a portable policy prompt using the native Cursor event contract. */ +/** + * Render a portable policy prompt using the native Cursor event contract. + * Switches exhaustively on {@link CursorResponseKind} — the `never` default + * fails to compile if a new kind is ever added without a matching case. + * `contract.known === false` is not tested separately: the single + * `UNKNOWN_EVENT` fallback in events.ts always pairs `known: false` with + * `response: "neutral"`, so both collapse to the same `"{}"` branch. + */ export function toCursorResponse(prompt: Prompt, eventName: string): string { const contract = cursorEventContract(eventName); const message = formatPrompt(prompt); - if (!contract.known || contract.response === "neutral" || contract.response === "plugin-paths") return "{}"; - if (contract.response === "post-context" || contract.response === "session-context") { - return JSON.stringify({ additional_context: message }); + switch (contract.response) { + case "neutral": + case "plugin-paths": + return "{}"; + case "post-context": + case "session-context": + return capAdditionalContext(JSON.stringify({ additional_context: message })); + case "followup": + return JSON.stringify({ followup_message: message }); + case "compact-notice": + return JSON.stringify({ user_message: prompt.userMessage ?? message }); + case "submit-control": + return JSON.stringify({ continue: prompt.kind !== "block", user_message: prompt.userMessage ?? message }); + case "permission": { + if (prompt.kind === "inform") { + return JSON.stringify({ + permission: "allow", + ...permissionMessages(eventName, prompt.userMessage, prompt.reason ? message : undefined), + }); + } + const userMessage = prompt.kind === "ask" + ? `[downgraded from ask — Cursor does not enforce approval for this event]\n${message}` + : message; + return JSON.stringify({ + permission: "deny", + ...permissionMessages(eventName, userMessage, userMessage), + }); + } + default: { + const exhaustive: never = contract.response; + return exhaustive; + } } - if (contract.response === "followup") return JSON.stringify({ followup_message: message }); - if (contract.response === "compact-notice") return JSON.stringify({ user_message: prompt.userMessage ?? message }); - if (contract.response === "submit-control") { - return JSON.stringify({ continue: prompt.kind !== "block", user_message: prompt.userMessage ?? message }); - } - if (prompt.kind === "inform") { - return JSON.stringify({ - permission: "allow", - ...permissionMessages(eventName, prompt.userMessage, prompt.reason ? message : undefined), - }); - } - const userMessage = prompt.kind === "ask" - ? `[downgraded from ask — Cursor does not enforce approval for this event]\n${message}` - : message; - return JSON.stringify({ - permission: "deny", - ...permissionMessages(eventName, userMessage, userMessage), - }); } -/** Convert a shared lifecycle handler's output to the native Cursor envelope. */ -export function toCursorLifecycleResponse(stdout: string, eventName: string): string { +/** + * Convert a shared lifecycle handler's output to the native Cursor envelope. + * The `neutral` and empty-`text` short circuits run before the switch (they + * apply identically across several {@link CursorResponseKind} values), so + * only the remaining 7 kinds need a case — `never` below still catches a + * future kind added without updating this function. This is the single + * point every Cursor stdout passes through exactly once (see `handle.ts`'s + * `handleHook`), so `budget` — when supplied — is reserved from and + * recorded into here, never at the inner `toCursorResponse` pre-cap (that + * one's output is re-capped here again on the native-passthrough branch + * below, so budgeting it too would double-count the same contribution). + * @param stdout - The shared handler's raw stdout for this hook invocation. + * @param eventName - Cursor's raw `hook_event_name`. + * @param budget - Shared `additional_context` budget context (see + * {@link CursorBudgetContext}); `undefined` falls back to the flat + * per-response 10,000-char cap, unbudgeted. + */ +export function toCursorLifecycleResponse(stdout: string, eventName: string, budget?: CursorBudgetContext): string { const contract = cursorEventContract(eventName); const native = parseNativeCursorStdout(stdout, eventName); - if (native !== null) return native; + if (native !== null) return capAdditionalContextWithBudget(native, budget); let text = stdout; let decision: "allow" | "deny" | "ask" | undefined; let userMessage = ""; @@ -97,27 +134,37 @@ export function toCursorLifecycleResponse(stdout: string, eventName: string): st } if (contract.response === "neutral") return "{}"; if (!text) return contract.response === "permission" ? '{"permission":"allow"}' : "{}"; - if (contract.response === "session-context" || contract.response === "post-context") { - return JSON.stringify({ additional_context: text }); - } - if (contract.response === "permission") { - const permission = decision === "deny" || decision === "ask" ? "deny" : "allow"; - const denied = permission === "deny"; - // Cursor subagentStart can gate creation but has no model-context channel. - // Drop shared context and its "injected" notice on allow: preserving either - // would claim delivery the native event contract cannot perform. - if (eventName === "subagentStart" && !denied) return '{"permission":"allow"}'; - return JSON.stringify({ - permission, - ...permissionMessages( - eventName, - userMessage || (denied ? decisionMessage || agentMessage : ""), - agentMessage || (denied ? decisionMessage || userMessage : structured ? "" : text), - ), - }); + switch (contract.response) { + case "session-context": + case "post-context": + return capAdditionalContextWithBudget(JSON.stringify({ additional_context: text }), budget); + case "permission": { + const permission = decision === "deny" || decision === "ask" ? "deny" : "allow"; + const denied = permission === "deny"; + // Cursor subagentStart can gate creation but has no model-context channel. + // Drop shared context and its "injected" notice on allow: preserving either + // would claim delivery the native event contract cannot perform. + if (eventName === "subagentStart" && !denied) return '{"permission":"allow"}'; + return JSON.stringify({ + permission, + ...permissionMessages( + eventName, + userMessage || (denied ? decisionMessage || agentMessage : ""), + agentMessage || (denied ? decisionMessage || userMessage : structured ? "" : text), + ), + }); + } + case "followup": + return JSON.stringify({ followup_message: text }); + case "compact-notice": + return JSON.stringify({ user_message: text }); + case "submit-control": + return JSON.stringify({ continue: true, user_message: text }); + case "plugin-paths": + return "{}"; + default: { + const exhaustive: never = contract.response; + return exhaustive; + } } - if (contract.response === "followup") return JSON.stringify({ followup_message: text }); - if (contract.response === "compact-notice") return JSON.stringify({ user_message: text }); - if (contract.response === "submit-control") return JSON.stringify({ continue: true, user_message: text }); - return "{}"; } diff --git a/src/runtime/handle.ts b/src/runtime/handle.ts index 492db80..212c43d 100644 --- a/src/runtime/handle.ts +++ b/src/runtime/handle.ts @@ -1,10 +1,12 @@ +import { join } from "node:path"; import { projectLayout } from "../config/layout"; import { detectFramework } from "../policy/detect-framework"; import { detectCreationIntent } from "../policy/creation-intent"; import { recordBrainstormRequired } from "../tracking/session-state"; import { withTrack } from "../tracking/store"; import { normalizeEvent } from "./normalize"; -import { defaultStateDir, trackFile } from "./paths"; +import { defaultStateDir, projectHash, trackFile } from "./paths"; +import { fuseHarnessHome } from "./home-state"; import { designLifecycle } from "./design-lifecycle"; import { promptSubmitContext } from "./inject-context"; import { lifecycleStdout } from "./lifecycle-bridge"; @@ -22,6 +24,7 @@ import { codexPromptOrigin } from "./confirm/codex-prompt-origin"; import { cursorProjectCwd } from "../adapters/cursor/context"; import { toCursorLifecycleResponse } from "../adapters/cursor/respond"; import type { HandleOptions, HandleOutcome } from "./handle-types"; +import type { NormalizedEvent } from "./normalize"; export type { HandleOptions, HandleOutcome } from "./handle-types"; /** Raw Claude hook event name from a payload (empty when absent). */ @@ -29,6 +32,62 @@ function rawEventName(payload: Record): string { return typeof payload.hook_event_name === "string" ? payload.hook_event_name : ""; } +/** + * `payload.tool_input` parsed into an object when it's a JSON STRING — + * Cursor's real wire format for `beforeMCPExecution`/`afterMCPExecution` + * (ground truth), unlike every other harness (and Cursor's own + * `preToolUse`/`postToolUse`), which always sends it as an object already. + * `undefined` when `tool_input` is already an object, absent, or fails to + * parse into one (fail-open — the caller then keeps the original value). + * @param payload - The raw hook payload. + */ +function cursorParsedToolInput(payload: Record): Record | undefined { + const raw = payload.tool_input; + if (typeof raw !== "string") return undefined; + try { + const parsed: unknown = JSON.parse(raw); + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Record) : undefined; + } catch { + return undefined; + } +} + +/** + * `id === "cursor"` only: project the already-resolved canonical `tool_name` + * (`event.tool`, normalized by {@link normalizeEvent}) and `cwd` (the project + * root resolved via `cursorProjectCwd`, already applied to `opts.cwd`) onto a + * shallow payload copy — the single passage point for every downstream + * consumer that reads `payload.tool_name`/`payload.cwd`/`payload.tool_input` + * RAW instead of `event.tool`/`opts.cwd`/`event.input` (lifecycle-bridge's + * `failure-lesson.ts`/`agent-memory.ts`, handle-scope-async's aipilot/memory + * dispatchers — including `doc-cache-gate.ts`'s `libraryOf`, which never + * `JSON.parse`s a string `tool_input` itself — and the seo scope's + * `post-tool-use.ts`). `tool_input` is additionally replaced by its parsed + * object form via {@link cursorParsedToolInput} when Cursor sent it as a + * JSON string (`beforeMCPExecution`/`afterMCPExecution`). Cursor's own wire + * values ("Shell", `MCP:`, a bare `workspace_roots` array with no + * `cwd` field, a stringified `tool_input`, …) are preserved under + * `cursor_tool_name`/`cursor_cwd`/`cursor_tool_input` so nothing is lost. + * Every other harness id is untouched (returns the SAME object, + * byte-identical). + * @param payload - The raw hook payload. + * @param event - The already-normalized event (`event.tool` is canonical). + * @param cwd - The resolved project root for this invocation. + * @param id - Harness adapter id. + */ +function cursorRawPayloadProjection(payload: Record, event: NormalizedEvent, cwd: string, id: string): Record { + if (id !== "cursor") return payload; + const parsedToolInput = cursorParsedToolInput(payload); + return { + ...payload, + cursor_tool_name: payload.tool_name, + cursor_cwd: payload.cwd, + tool_name: event.tool, + cwd, + ...(parsedToolInput ? { cursor_tool_input: payload.tool_input, tool_input: parsedToolInput } : {}), + }; +} + /** * The full hook handler: on a PRE event it gates the tool-use (stateless guards * then APEX gates from the session track) and returns the native response; on a @@ -41,6 +100,10 @@ async function handleHookCore(id: string, payload: Record, opts const cursorCwd = cursorProjectCwd(event.cwd, event.workspaceRoots ?? [], event.filePath, opts.cwd); if (cursorCwd !== opts.cwd) opts = { ...opts, cwd: cursorCwd }; } + // Single passage point (see cursorRawPayloadProjection doc): every raw-payload + // consumer below this line gets the canonical tool_name/cwd on Cursor; every + // other harness id gets `payload` back untouched (byte-identical object). + const hookPayload = cursorRawPayloadProjection(payload, event, opts.cwd, id); const rawPrompt = payload.prompt; const userPrompt = typeof rawPrompt === "string" || Array.isArray(rawPrompt) ? promptText(rawPrompt) : undefined; if (id === "codex" && rawEventName(payload) === "UserPromptSubmit" && userPrompt !== undefined) { @@ -75,11 +138,11 @@ async function handleHookCore(id: string, payload: Record, opts if (id === "codex" && rawEventName(payload) === "SessionStart") resyncCodexAgents(); // Async per-scope lifecycle (aipilot cache handlers + memory-neural Graphiti). - const asyncOut = await asyncScopeStdout(opts.scope, rawEventName(payload), payload, opts.cwd, opts.now, id); + const asyncOut = await asyncScopeStdout(opts.scope, rawEventName(payload), hookPayload, opts.cwd, opts.now, id); if (asyncOut !== null) return { stdout: asyncOut, exit: 0 }; // Ported lifecycle/session/context hooks (SessionStart, SubagentStart/Stop, etc.). - const life = lifecycleStdout(payload, opts.cwd, opts.scope ?? "core", opts.now, id); + const life = lifecycleStdout(hookPayload, opts.cwd, opts.scope ?? "core", opts.now, id); if (life !== null) { // Claude-Code-only: attachBudgetRecap's systemMessage envelope assumes the // Claude adapter's stdout shape (mirrors the designLifecycle gate above). @@ -99,18 +162,36 @@ async function handleHookCore(id: string, payload: Record, opts } if (event.phase === "post") { - return handlePost({ id, payload, event, framework, mcpDir, designCacheDir, file, opts }); + return handlePost({ id, payload: hookPayload, event, framework, mcpDir, designCacheDir, file, opts }); } - return handlePre({ id, payload, event, framework, mcpDir, designCacheDir, file, opts }); + return handlePre({ id, payload: hookPayload, event, framework, mcpDir, designCacheDir, file, opts }); } /** * Run one hook and adapt every Cursor scope outcome at the common runtime exit. * Other harnesses retain the core handler's stdout and exit status unchanged. + * Cursor's shared `additional_context` budget context (see + * `../adapters/cursor/context-budget.ts`) is assembled here too — this is + * the single point every Cursor stdout passes through exactly once, so it's + * also the single point that reserves from and records into the registry. + * With no `session_id`/`conversation_id` at all, `sessionId` is `""` — the + * registry key would degenerate to one bucket shared by every session-less + * call on the same (cwd, event) pair, so `budget` stays `undefined` instead + * (falls back to the flat per-response cap in `toCursorLifecycleResponse`, + * with zero registry I/O). `stateDir` honors `opts.home` (test-only OS home + * override, see `HandleOptions`) so tests never need the real `os.homedir()`. */ export async function handleHook(id: string, payload: Record, opts: HandleOptions): Promise { const outcome = await handleHookCore(id, payload, opts); if (id !== "cursor") return outcome; - return { ...outcome, stdout: toCursorLifecycleResponse(outcome.stdout, rawEventName(payload)) }; + const eventName = rawEventName(payload); + const cursorEvent = normalizeEvent(id, payload); + const cwd = cursorProjectCwd(cursorEvent.cwd, cursorEvent.workspaceRoots ?? [], cursorEvent.filePath, opts.cwd); + const sessionId = cursorEvent.sessionId; + const generationId = typeof payload.generation_id === "string" && payload.generation_id ? payload.generation_id : undefined; + const toolUseId = typeof payload.tool_use_id === "string" && payload.tool_use_id ? payload.tool_use_id : undefined; + const stateDir = join(fuseHarnessHome(opts.home), "state", projectHash(cwd)); + const budget = sessionId ? { stateDir, sessionId, event: eventName, generationId, toolUseId } : undefined; + return { ...outcome, stdout: toCursorLifecycleResponse(outcome.stdout, eventName, budget) }; } diff --git a/src/runtime/lifecycle/aipilot/dispatch-aipilot.ts b/src/runtime/lifecycle/aipilot/dispatch-aipilot.ts index 44a4f0b..69b81e0 100644 --- a/src/runtime/lifecycle/aipilot/dispatch-aipilot.ts +++ b/src/runtime/lifecycle/aipilot/dispatch-aipilot.ts @@ -90,7 +90,13 @@ export async function dispatchAipilot(event: string, payload: Record/rules` dir whose plugin folder @@ -14,6 +21,7 @@ import { existsSync, readdirSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { maxSemver } from "../../util/semver"; +import { resolveCursorPluginRoot } from "../../adapters/cursor/plugin-root"; /** Immediate child dir names of `dir`, or [] when unreadable. */ function children(dir: string): string[] { @@ -72,6 +80,14 @@ export function resolveRulesRoot( cwd: string, env: Record = process.env, ): string { + if (id === "cursor") { + const result = resolveCursorPluginRoot(env, cwd); + if (result.root) return result.root; + process.stderr.write( + `[fuse-harness] cursor: no plugin root proven (checked: ${result.checked.join("; ")}); rules root falls back to ${cwd}\n`, + ); + return cwd; + } if (env.CLAUDE_PLUGIN_ROOT) return env.CLAUDE_PLUGIN_ROOT; if (env.KIMI_PLUGIN_ROOT) return env.KIMI_PLUGIN_ROOT; const home = env.HOME ?? homedir(); diff --git a/test/confirm-codex-provenance.test.ts b/test/confirm-codex-provenance.test.ts index 7a310bb..7cadf32 100644 --- a/test/confirm-codex-provenance.test.ts +++ b/test/confirm-codex-provenance.test.ts @@ -125,15 +125,21 @@ test("agent metadata remains irrelevant to non-Codex UserPromptSubmit handling", for (const id of ["claude-code", "kimi", "cursor"]) { const opts: HandleOptions = { now: 1000, cwd: temp("confirm-cross-cwd"), home: temp("confirm-cross-home") }; const event = "UserPromptSubmit"; + // Nonces strip a-f so no DEV_VERBS alternative (e.g. "add") can appear + // inside the hex UUID by chance (DEV_VERBS has no \b word boundary — see + // src/policy/claude-md-context.ts). Kept distinct (root vs attributed) + // so inject-dedup's 3s window does not collapse them into one prompt. + const rootNonce = randomUUID().replace(/[a-f]/g, ""); + const attributedNonce = `${randomUUID().replace(/[a-f]/g, "")}-1`; const root = await handleHook(id, { hook_event_name: event, session_id: `cross-root-${randomUUID()}`, - prompt: `ordinary root prompt ${randomUUID()}`, + prompt: `ordinary root prompt ${rootNonce}`, }, opts); const attributed = await handleHook(id, { hook_event_name: event, session_id: `cross-agent-${randomUUID()}`, - prompt: `ordinary attributed prompt ${randomUUID()}`, + prompt: `ordinary attributed prompt ${attributedNonce}`, agent_id: "agent-1", agent_type: "worker", }, opts); diff --git a/test/cursor-authentic-fixtures-cases.ts b/test/cursor-authentic-fixtures-cases.ts new file mode 100644 index 0000000..72c54f6 --- /dev/null +++ b/test/cursor-authentic-fixtures-cases.ts @@ -0,0 +1,51 @@ +/** One fixture's expected outcome + which normalized-extraction fields are meaningful to check. */ +export interface FixtureCase { + /** Path under `test/fixtures/cursor/`, e.g. `preToolUse/05-read-minimal.json`. */ + relPath: string; + /** MCP fixtures assert stdout bytes + exit ONLY (mandate scope). */ + isMcp: boolean; + /** + * Expected exact stdout bytes, or `null` for the one fixture (`sessionStart`) + * whose `additional_context` embeds this repo's own live harness version and + * git-branch reconciliation snapshot — asserted structurally instead (see + * cursor-native-bytes.test.ts for the identical, more-detailed rationale). + */ + expectedStdout: string | null; + /** Where the raw stdin carries the file path to compare against `normalized.filePath`. */ + filePathSource?: "top" | "tool_input"; + /** True when the raw stdin carries a top-level `cwd` (Shell-shaped events). */ + hasTopCwd?: boolean; +} + +/** + * All 23 fixtures under `test/fixtures/cursor/` (8 authentic + 14 + * binary-verified synthetic + 1 synthetic multi-root augmentation), each + * mapped to its neutral/allow-path stdout — captured via direct `handleHook` + * invocation (rebased onto an isolated temp project dir) and cross-checked + * against the documented native contract in native-schemas.ts. + */ +export const FIXTURE_CASES: FixtureCase[] = [ + { relPath: "afterFileEdit/01-synthetic.json", isMcp: false, expectedStdout: "{}", filePathSource: "top" }, + { relPath: "afterMCPExecution/01-synthetic.json", isMcp: true, expectedStdout: "{}" }, + { relPath: "afterShellExecution/01-synthetic.json", isMcp: false, expectedStdout: "{}", hasTopCwd: true }, + { relPath: "beforeMCPExecution/01-synthetic.json", isMcp: true, expectedStdout: '{"permission":"allow"}' }, + { relPath: "beforeReadFile/01-synthetic.json", isMcp: false, expectedStdout: '{"permission":"allow"}', filePathSource: "top" }, + { relPath: "beforeShellExecution/01-synthetic.json", isMcp: false, expectedStdout: '{"permission":"allow"}', hasTopCwd: true }, + { relPath: "beforeSubmitPrompt/01-agent-mode-no-attachments.json", isMcp: false, expectedStdout: "{}" }, + { relPath: "postToolUse/01-synthetic.json", isMcp: false, expectedStdout: "{}", filePathSource: "tool_input", hasTopCwd: true }, + { relPath: "postToolUseFailure/01-synthetic.json", isMcp: false, expectedStdout: "{}", filePathSource: "tool_input" }, + { relPath: "preCompact/01-synthetic.json", isMcp: false, expectedStdout: "{}" }, + { relPath: "preToolUse/01-task-main-conversation.json", isMcp: false, expectedStdout: '{"permission":"allow"}' }, + { relPath: "preToolUse/02-shell-top-level-cwd.json", isMcp: false, expectedStdout: '{"permission":"allow"}', hasTopCwd: true }, + { relPath: "preToolUse/03-write-subagent-null-transcript.json", isMcp: false, expectedStdout: '{"permission":"allow"}', filePathSource: "tool_input" }, + { relPath: "preToolUse/04-grep-glob-output-mode.json", isMcp: false, expectedStdout: '{"permission":"allow"}', filePathSource: "tool_input" }, + { relPath: "preToolUse/05-read-minimal.json", isMcp: false, expectedStdout: '{"permission":"allow"}', filePathSource: "tool_input" }, + { relPath: "preToolUse/06-task-resume-interrupt.json", isMcp: false, expectedStdout: '{"permission":"allow"}' }, + { relPath: "preToolUse/07-multi-root-synthetic.json", isMcp: false, expectedStdout: '{"permission":"allow"}', filePathSource: "tool_input" }, + { relPath: "sessionEnd/01-synthetic.json", isMcp: false, expectedStdout: "{}" }, + { relPath: "sessionStart/01-empty-window-claude-user-config.json", isMcp: false, expectedStdout: null }, + { relPath: "stop/01-synthetic.json", isMcp: false, expectedStdout: "{}" }, + { relPath: "subagentStart/01-synthetic.json", isMcp: false, expectedStdout: '{"permission":"allow"}' }, + { relPath: "subagentStop/01-synthetic.json", isMcp: false, expectedStdout: "{}" }, + { relPath: "workspaceOpen/01-synthetic.json", isMcp: false, expectedStdout: "{}" }, +]; diff --git a/test/cursor-authentic-fixtures.test.ts b/test/cursor-authentic-fixtures.test.ts new file mode 100644 index 0000000..ea0e393 --- /dev/null +++ b/test/cursor-authentic-fixtures.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleHook } from "../src/runtime/handle"; +import { normalizeEvent } from "../src/runtime/normalize"; +import { FIXTURE_CASES } from "./cursor-authentic-fixtures-cases"; + +const FIXTURES_ROOT = join(import.meta.dir, "fixtures", "cursor"); + +/** Recursively list every `*.json` fixture path under `FIXTURES_ROOT`, relative to it. */ +function walkFixtures(dir: string, prefix = ""): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const abs = join(dir, name); + const rel = prefix ? join(prefix, name) : name; + if (statSync(abs).isDirectory()) out.push(...walkFixtures(abs, rel)); + else if (name.endsWith(".json")) out.push(rel); + } + return out.sort(); +} + +/** + * Rewrite every occurrence of the fixtures' sanitized home placeholder + * (`/Users/user`) to an isolated temp project dir, so `handleHook`'s internal + * cache/state writes land there instead of attempting a real, unwritable + * `/Users/user/**` path on the test machine (verified: without this rebase, + * `postToolUse`/`afterMCPExecution`-shaped fixtures crash with `EACCES: + * permission denied, mkdir '/Users/user'` from `src/cache/store.ts`). + * @param stdin - The fixture's raw `stdin` object (read-only; the on-disk + * fixture file itself is never mutated). + * @param projectDir - The isolated temp directory standing in for `/Users/user`. + */ +function rebase(stdin: Record, projectDir: string): Record { + return JSON.parse(JSON.stringify(stdin).replaceAll("/Users/user", projectDir)) as Record; +} + +/** Load one fixture's `stdin` field from disk (unmodified). */ +function loadFixture(relPath: string): Record { + const raw = JSON.parse(readFileSync(join(FIXTURES_ROOT, relPath), "utf8")) as { stdin: Record }; + return raw.stdin; +} + +test("every fixture on disk has exactly one matching case (no drift between the two)", () => { + const onDisk = walkFixtures(FIXTURES_ROOT); + const cased = FIXTURE_CASES.map((c) => c.relPath).sort(); + expect(cased).toEqual(onDisk); +}); + +for (const testCase of FIXTURE_CASES) { + test(`Cursor fixture ${testCase.relPath}: handleHook stdout/exit${testCase.isMcp ? " (MCP: bytes only)" : " + normalized extraction"}`, async () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-authentic-fixture-")); + try { + const rawStdin = loadFixture(testCase.relPath); + const stdin = rebase(rawStdin, cwd); + // F1: `home: cwd` keeps the Cursor budget registry (see + // `handleHook`/`context-budget.ts`) off the real `os.homedir()` — any + // write lands under `/.fuse-harness/state/...`, cleaned up below. + const outcome = await handleHook("cursor", stdin, { now: 1_700_000_000_000, cwd, scope: "core", home: cwd }); + + expect(outcome.exit, testCase.relPath).toBe(0); + if (testCase.expectedStdout === null) { + // sessionStart only: byte-unstable (embeds live harness version/git state). + const parsed = JSON.parse(outcome.stdout) as Record; + expect(Object.keys(parsed), testCase.relPath).toEqual(["additional_context"]); + expect(typeof parsed.additional_context, testCase.relPath).toBe("string"); + } else { + expect(outcome.stdout, testCase.relPath).toBe(testCase.expectedStdout); + } + if (testCase.isMcp) return; // MCP fixtures: stdout bytes only (mandate scope). + + const normalized = normalizeEvent("cursor", stdin); + const expectedSessionId = typeof stdin.session_id === "string" + ? stdin.session_id + : typeof stdin.conversation_id === "string" ? stdin.conversation_id : ""; + expect(normalized.sessionId, testCase.relPath).toBe(expectedSessionId); + expect(Array.isArray(stdin.workspace_roots) ? stdin.workspace_roots.length : 0, `${testCase.relPath} root count`) + .toBe(normalized.workspaceRoots?.length ?? 0); + if (Array.isArray(stdin.workspace_roots)) { + expect(normalized.workspaceRoots, `${testCase.relPath} root order`).toEqual(stdin.workspace_roots); + } + // An empty-string `cwd` (real Cursor Shell captures use `cwd: ""`) is not + // a valid absolute path, so `cursorAbsolutePath` correctly drops it to + // `undefined` rather than preserving the empty string verbatim. + if (testCase.hasTopCwd) { + expect(normalized.cwd, testCase.relPath).toBe((stdin.cwd as string | undefined) || undefined); + } + if (testCase.filePathSource === "top") { + expect(normalized.filePath, testCase.relPath).toBe(stdin.file_path as string | undefined); + } + if (testCase.filePathSource === "tool_input") { + const toolInput = stdin.tool_input as Record; + expect(normalized.filePath, testCase.relPath).toBe(toolInput.file_path as string | undefined); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); +} + +test("preToolUse multi-root fixture preserves both workspace roots in wire order", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-authentic-fixture-multiroot-")); + try { + const stdin = rebase(loadFixture("preToolUse/07-multi-root-synthetic.json"), cwd); + const normalized = normalizeEvent("cursor", stdin); + expect(normalized.workspaceRoots).toEqual([join(cwd, "project-a"), join(cwd, "project-b")]); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("Shell-shaped fixture preserves `command`; `commandCandidates` stays undefined (beforeMCPExecution-only per normalize.ts)", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-authentic-fixture-shell-")); + try { + const stdin = rebase(loadFixture("preToolUse/02-shell-top-level-cwd.json"), cwd); + const normalized = normalizeEvent("cursor", stdin); + const toolInput = stdin.tool_input as Record; + expect(normalized.command).toBe(toolInput.command as string | undefined); + expect(normalized.commandCandidates).toBeUndefined(); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/test/cursor-cli-p0.test.ts b/test/cursor-cli-p0.test.ts index 72b4942..8afd754 100644 --- a/test/cursor-cli-p0.test.ts +++ b/test/cursor-cli-p0.test.ts @@ -1,12 +1,16 @@ import { expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; const simBin = process.env.SIM_BIN; -const bin = simBin +/** Resolved CLI entry point (source `bin.ts`, or a `SIM_BIN` override for the compiled binary). */ +export const bin: string = simBin ? (isAbsolute(simBin) ? simBin : resolve(import.meta.dir, "..", simBin)) : join(import.meta.dir, "..", "src", "cli", "bin.ts"); -const runtime = simBin ? "node" : "bun"; +/** `node` when running a compiled `SIM_BIN`, else `bun` for the TypeScript source. */ +export const runtime: "node" | "bun" = simBin ? "node" : "bun"; const commands = [ { name: "root-delete", value: ["rm", "-rf", "/"].join(" "), permission: "deny" }, { name: "source-sed", value: ["sed", "-i", "'s/a/b/'", "src/app.ts"].join(" "), permission: "deny" }, @@ -14,24 +18,50 @@ const commands = [ { name: "safe", value: ["ls", "-la"].join(" "), permission: "allow" }, ] as const; +/** + * Run `fn` with a dedicated tmp dir used as both the child's `cwd` and its + * `CURSOR_PROJECT_DIR`, so the harness's own side-channel writes (docs cache, + * session tracks keyed by the project dir) never land in this checkout. + * Always cleaned up, even if `fn` throws. + */ +function withIsolatedCwd(fn: (cwd: string) => T): T { + const cwd = mkdtempSync(join(tmpdir(), "cursor-cli-p0-")); + try { + return fn(cwd); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +} + +// `HOME: cwd` (F1): the spawned child reads HOME fresh at process start, so +// any Cursor `additional_context` budget registry write lands under +// `/.fuse-harness/state/...` instead of the real +// `~/.fuse-harness/state/...` — cleaned up by `withIsolatedCwd`'s rmSync. function runCursor(payload: Record, env: Record = {}): { exit: number; permission: string; stdout: string } { - const child = spawnSync(runtime, [bin, "hook", "cursor", "core"], { - input: JSON.stringify(payload), - encoding: "utf8", - env: { ...process.env, FUSE_ENFORCE_TTL_SEC: "3600", ...env }, + return withIsolatedCwd((cwd) => { + const child = spawnSync(runtime, [bin, "hook", "cursor", "core"], { + input: JSON.stringify(payload), + encoding: "utf8", + cwd, + env: { ...process.env, FUSE_ENFORCE_TTL_SEC: "3600", CURSOR_PROJECT_DIR: cwd, HOME: cwd, ...env }, + }); + const stdout = child.stdout.trim(); + const permission = stdout ? (JSON.parse(stdout) as { permission?: string }).permission ?? "allow" : "allow"; + return { exit: child.status ?? 1, permission, stdout }; }); - const stdout = child.stdout.trim(); - const permission = stdout ? (JSON.parse(stdout) as { permission?: string }).permission ?? "allow" : "allow"; - return { exit: child.status ?? 1, permission, stdout }; } -function runRaw(id: string, input: string): { exit: number; stdout: string } { - const child = spawnSync(runtime, [bin, "hook", id, "core"], { - input, - encoding: "utf8", - env: { ...process.env, FUSE_ENFORCE_TTL_SEC: "3600" }, +/** Spawn the CLI for one harness id/scope and return raw stdout (never trimmed). */ +export function runRaw(id: string, input: string): { exit: number; stdout: string } { + return withIsolatedCwd((cwd) => { + const child = spawnSync(runtime, [bin, "hook", id, "core"], { + input, + encoding: "utf8", + cwd, + env: { ...process.env, FUSE_ENFORCE_TTL_SEC: "3600", CURSOR_PROJECT_DIR: cwd, HOME: cwd }, + }); + return { exit: child.status ?? 1, stdout: child.stdout }; }); - return { exit: child.status ?? 1, stdout: child.stdout }; } test("Cursor CLI gates documented beforeShellExecution and preToolUse Shell payloads", () => { @@ -47,7 +77,21 @@ test("Cursor CLI gates documented beforeShellExecution and preToolUse Shell payl } }); -test("Cursor CLI keeps documented afterFileEdit observe-only without unsupported fields", () => { +// afterFileEdit: verdict computed on edits[] but not emitted — documented +// loss, Cursor's afterFileEdit validator reads no response fields (agent-cli +// 190.index.js). Why: under `--scope solid` specifically, `handle-post.ts` +// DOES fan this event's edits[] into a real SOLID file-size verdict via +// `checkFileSize`/`firstFileMatch` — but `postOutcome`'s `cursorAfterFileEdit` +// short-circuit (src/runtime/post-outcome.ts:50-52) always returns `{}` +// before that verdict (or any other scope's warning) can be emitted, because +// Cursor's own validator for this event accepts no response fields at all — +// emitting anything else would be undeliverable, not merely unread. This +// test runs under `--scope core`, where no SOLID verdict is computed in the +// first place (checkFileSize is solid-scope-only), and the edited path is a +// plain `.ts` file that `activity.ts` never classifies for doc/agent/ref +// credit — so there is no OTHER observable tracking side effect here to +// assert either; `{}` is genuinely the full, correct, and only outcome. +test("Cursor CLI: afterFileEdit verdict computed on edits[] but not emitted — documented loss, Cursor's afterFileEdit validator reads no response fields (agent-cli 190.index.js)", () => { const filePath = join(process.cwd(), `.cursor-p0-${process.pid}.ts`); const result = runCursor({ hook_event_name: "afterFileEdit", diff --git a/test/cursor-context-budget-guards.test.ts b/test/cursor-context-budget-guards.test.ts new file mode 100644 index 0000000..53dbbb4 --- /dev/null +++ b/test/cursor-context-budget-guards.test.ts @@ -0,0 +1,89 @@ +/** + * Split out of `cursor-context-budget.test.ts` (SOLID 200-line ceiling — see + * `FUSE_SOLID_MAX_LINES`): the challenger-reported F2/F3/F4 guards on the + * Cursor `additional_context` shared-budget registry. + * - F2: a degenerate key (no session_id/conversation_id) must skip the + * registry entirely instead of sharing one bucket across sessions. + * - F3: `tool_use_id` must join the registry key so Cursor's per-tool-call + * merge semantics on preToolUse/postToolUse/postToolUseFailure are honored. + * - F4: an unbudgeted flat-cap pass followed by a budgeted re-cap of the SAME + * stdout (lifecycle-bridge.ts/handle-scope-async.ts, then handle.ts) must + * never carry more than one {@link TRUNCATION_MARKER}. + */ +import { expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { toCursorLifecycleResponse } from "../src/adapters/cursor/respond"; +import { TRUNCATION_MARKER } from "../src/adapters/cursor/context-limit"; +import { handleHook } from "../src/runtime/handle"; +import { isolatedStateDir } from "./cursor-context-budget.test"; + +function tmp(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)); +} +function preToolUseWith(length: number): string { + return JSON.stringify({ permission: "allow", additional_context: "x".repeat(length) }); +} + +test("F2: cursor sessionStart with no session_id/conversation_id never touches the shared registry (degenerate-key guard)", async () => { + const cwd = tmp("cursor-budget-f2-"); + const stateDir = isolatedStateDir(cwd, cwd); + try { + const payload = { hook_event_name: "sessionStart" }; // no session_id, no conversation_id + const first = await handleHook("cursor", payload, { now: Date.now(), cwd, scope: "core", home: cwd }); + const second = await handleHook("cursor", payload, { now: Date.now(), cwd, scope: "core", home: cwd }); + // No registry write at all (not even an empty file) — the flat cap alone + // decided both responses, so two session-less calls never share a budget. + expect(existsSync(join(stateDir, "cursor-context-budget.json"))).toBe(false); + expect(second.stdout).toBe(first.stdout); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("F3: two preToolUse hooks, same session/generation, different tool_use_id — independent budgets, neither truncated", () => { + const stateDir = tmp("cursor-budget-f3-"); + const base = { stateDir, sessionId: "s-f3", event: "preToolUse", generationId: "gen-1" }; + try { + const outA = toCursorLifecycleResponse(preToolUseWith(7000), "preToolUse", { ...base, toolUseId: "call-a" }); + const outB = toCursorLifecycleResponse(preToolUseWith(7000), "preToolUse", { ...base, toolUseId: "call-b" }); + expect(outA).toBe(preToolUseWith(7000)); + expect(outB).toBe(preToolUseWith(7000)); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test("F3: two preToolUse hooks with the SAME tool_use_id share one budget — the second truncates", () => { + const stateDir = tmp("cursor-budget-f3b-"); + const budget = { stateDir, sessionId: "s-f3b", event: "preToolUse", generationId: "gen-1", toolUseId: "call-same" }; + try { + toCursorLifecycleResponse(preToolUseWith(7000), "preToolUse", budget); + const second = toCursorLifecycleResponse(preToolUseWith(7000), "preToolUse", budget); + const { additional_context } = JSON.parse(second) as { additional_context: string }; + expect(additional_context.length).toBeLessThan(7000); + expect(additional_context.endsWith(TRUNCATION_MARKER)).toBe(true); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test("F4: an unbudgeted flat-cap pass followed by a budgeted re-cap of the same stdout carries exactly one marker", () => { + const stateDir = tmp("cursor-budget-f4-"); + const now = Date.now(); + const key = "s-f4|sessionStart||"; + writeFileSync(join(stateDir, "cursor-context-budget.json"), JSON.stringify({ [key]: [{ at: now, length: 4000 }] })); + try { + const generic = JSON.stringify({ hookSpecificOutput: { additionalContext: "A".repeat(12_000) } }); + // Pass 1 mirrors lifecycle-bridge.ts/handle-scope-async.ts: unbudgeted, flat 10000 cap. + const v1 = toCursorLifecycleResponse(generic, "sessionStart"); + // Pass 2 mirrors handle.ts's handleHook wrapper: budgeted re-cap of that same stdout. + const v2 = toCursorLifecycleResponse(v1, "sessionStart", { stateDir, sessionId: "s-f4", event: "sessionStart", now }); + const { additional_context } = JSON.parse(v2) as { additional_context: string }; + expect(additional_context.split(TRUNCATION_MARKER).length - 1).toBe(1); + expect(additional_context.length).toBeLessThanOrEqual(10_000 - 4000 - 9); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } +}); diff --git a/test/cursor-context-budget.test.ts b/test/cursor-context-budget.test.ts new file mode 100644 index 0000000..0f606d1 --- /dev/null +++ b/test/cursor-context-budget.test.ts @@ -0,0 +1,187 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { toCursorLifecycleResponse } from "../src/adapters/cursor/respond"; +import { reserveAdditionalContext, recordAdditionalContext } from "../src/adapters/cursor/context-budget"; +import { defaultStateDir, projectHash } from "../src/runtime/paths"; +import { fuseHarnessHome } from "../src/runtime/home-state"; +import { handleHook } from "../src/runtime/handle"; + +const BIN = join(import.meta.dir, "..", "src", "cli", "bin.ts"); +const TRUNCATION_MARKER = "\n[fuse-harness] additional_context truncated to Cursor's 10000-char limit"; + +function tmp(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)); +} +function registryOf(stateDir: string): Record { + return JSON.parse(readFileSync(join(stateDir, "cursor-context-budget.json"), "utf8")); +} +function sessionStartWith(length: number): string { + return JSON.stringify({ additional_context: "x".repeat(length) }); +} +/** + * Same `${home}/.fuse-harness/state/` layout as + * `handleHook`'s Cursor budget `stateDir` (see `src/runtime/handle.ts`) — + * used by in-process `handleHook` tests below (and by + * `cursor-context-budget-guards.test.ts`, split out for the SOLID line + * ceiling) so they never touch the real `os.homedir()` (F1: no real + * `~/.fuse-harness/state` writes from tests). + */ +export function isolatedStateDir(home: string, cwd: string): string { + return join(fuseHarnessHome(home), "state", projectHash(cwd)); +} +function run(id: string, scope: string, payload: unknown, cwd: string): { stdout: string; status: number | null } { + const r = spawnSync("bun", [BIN, "hook", id, scope], { input: JSON.stringify(payload), cwd, encoding: "utf8" }); + return { stdout: r.stdout, status: r.status }; +} +test("1st hook: intact, registry written with the full length", () => { + const stateDir = tmp("cursor-budget-1-"); + try { + const budget = { stateDir, sessionId: "s1", event: "sessionStart" }; + const out = sessionStartWith(7140); + expect(toCursorLifecycleResponse(out, "sessionStart", budget)).toBe(out); + expect(registryOf(stateDir)["s1|sessionStart||"]).toEqual([{ at: expect.any(Number), length: 7140 }]); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test("2nd hook same key: truncated to 10000 - 7140 - 9 = 2851, registry holds 2 entries", () => { + const stateDir = tmp("cursor-budget-2-"); + try { + const budget = { stateDir, sessionId: "s2", event: "sessionStart" }; + toCursorLifecycleResponse(sessionStartWith(7140), "sessionStart", budget); + const rendered = toCursorLifecycleResponse(sessionStartWith(3000), "sessionStart", budget); + const { additional_context } = JSON.parse(rendered) as { additional_context: string }; + expect(additional_context.length).toBe(2851); + expect(additional_context.endsWith(TRUNCATION_MARKER)).toBe(true); + expect(registryOf(stateDir)["s2|sessionStart||"]?.map((e) => e.length)).toEqual([7140, 2851]); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test("3rd hook same key: budget exhausted, additional_context omitted, stderr warns", () => { + const stateDir = tmp("cursor-budget-3-"); + const originalWrite = process.stderr.write.bind(process.stderr); + let captured = ""; + process.stderr.write = ((chunk: string) => { captured += chunk; return true; }) as typeof process.stderr.write; + try { + const budget = { stateDir, sessionId: "s3", event: "sessionStart" }; + toCursorLifecycleResponse(sessionStartWith(7140), "sessionStart", budget); + toCursorLifecycleResponse(sessionStartWith(3000), "sessionStart", budget); + const rendered = toCursorLifecycleResponse(sessionStartWith(500), "sessionStart", budget); + expect(JSON.parse(rendered)).not.toHaveProperty("additional_context"); + expect(captured).toContain("budget exhausted"); + expect(captured).toContain("sessionStart"); + } finally { + process.stderr.write = originalWrite; + rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test("a different key (session/event/generation) is an independent budget", () => { + const stateDir = tmp("cursor-budget-4-"); + try { + const first = { stateDir, sessionId: "s4a", event: "sessionStart" }; + const second = { stateDir, sessionId: "s4b", event: "sessionStart" }; + toCursorLifecycleResponse(sessionStartWith(7140), "sessionStart", first); + const out = sessionStartWith(7140); + expect(toCursorLifecycleResponse(out, "sessionStart", second)).toBe(out); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test("an entry older than 10s is ignored: budget is back to full", () => { + const stateDir = tmp("cursor-budget-5-"); + try { + const t0 = 1_000_000_000_000; + recordAdditionalContext({ stateDir, sessionId: "s5", event: "sessionStart", now: t0, emitted: 9_999 }); + const { allowed } = reserveAdditionalContext({ stateDir, sessionId: "s5", event: "sessionStart", now: t0 + 11_000, wanted: 100 }); + expect(allowed).toBe(10_000); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test("unreadable registry directory fails open to the flat per-response cap", () => { + const root = tmp("cursor-budget-6-"); + const blockerFile = join(root, "blocker"); + writeFileSync(blockerFile, ""); + const unwritableStateDir = join(blockerFile, "state"); // parent segment is a FILE -> mkdirSync throws ENOTDIR + try { + const budget = { stateDir: unwritableStateDir, sessionId: "s6", event: "sessionStart" }; + const renderedOver = JSON.parse(toCursorLifecycleResponse(sessionStartWith(12_000), "sessionStart", budget)) as { additional_context: string }; + expect(renderedOver.additional_context.length).toBeLessThanOrEqual(10_000); + expect(renderedOver.additional_context.endsWith(TRUNCATION_MARKER)).toBe(true); + + const under = sessionStartWith(9_000); + expect(toCursorLifecycleResponse(under, "sessionStart", budget)).toBe(under); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("corrupt registry JSON fails open the same way", () => { + const stateDir = tmp("cursor-budget-6b-"); + writeFileSync(join(stateDir, "cursor-context-budget.json"), "{not valid json"); + try { + const budget = { stateDir, sessionId: "s6b", event: "sessionStart" }; + const under = sessionStartWith(9_000); + expect(toCursorLifecycleResponse(under, "sessionStart", budget)).toBe(under); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test("non-regression: claude-code and codex never touch the budget registry, stdout stays well-formed", () => { + // One call per fresh dir, not two on the same one: a pre-existing, unrelated + // dedup mechanism (inject-dedup.ts, 3s window) legitimately makes a 2nd + // SessionStart call on the SAME cwd/session omit fragments already injected + // by the 1st — that statefulness predates this change and isn't in scope. + const dirs = [tmp("cursor-budget-7-a-"), tmp("cursor-budget-7-b-"), tmp("cursor-budget-7-c-"), tmp("cursor-budget-7-d-")]; + try { + let i = 0; + for (const id of ["claude-code", "codex"]) { + const sessionCwd = dirs[i++]!; + const promptCwd = dirs[i++]!; + const sessionStart = run(id, "core", { hook_event_name: "SessionStart", session_id: "reg-sid", cwd: sessionCwd }, sessionCwd); + expect(sessionStart.status).toBe(0); + expect(JSON.parse(sessionStart.stdout)).toHaveProperty("hookSpecificOutput.hookEventName", "SessionStart"); + const promptSubmit = run(id, "core", { hook_event_name: "UserPromptSubmit", session_id: "reg-sid", cwd: promptCwd, prompt: "hello" }, promptCwd); + expect(promptSubmit.status).toBe(0); + expect(JSON.parse(promptSubmit.stdout)).toHaveProperty("hookSpecificOutput.hookEventName", "UserPromptSubmit"); + expect(() => registryOf(defaultStateDir(sessionCwd))).toThrow(); + expect(() => registryOf(defaultStateDir(promptCwd))).toThrow(); + } + } finally { + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }); + rmSync(defaultStateDir(dir), { recursive: true, force: true }); + } + } +}); + +test("end-to-end via handleHook: two scopes on the same cursor sessionStart share one budget", async () => { + // F1: `home: cwd` keeps this test off the real `os.homedir()` — the + // registry then lives under `/.fuse-harness/state/...`, nested inside + // the tmpdir already cleaned up below (see `isolatedStateDir`). + const cwd = tmp("cursor-budget-8-"); + const stateDir = isolatedStateDir(cwd, cwd); + try { + const payload = { hook_event_name: "sessionStart", session_id: "e2e-sid" }; + const lessons = await handleHook("cursor", payload, { now: Date.now(), cwd, scope: "lessons", home: cwd }); + const core = await handleHook("cursor", payload, { now: Date.now(), cwd, scope: "core", home: cwd }); + const lengthOf = (stdout: string): number => (stdout ? (JSON.parse(stdout) as { additional_context?: string }).additional_context?.length ?? 0 : 0); + const total = lengthOf(lessons.stdout) + lengthOf(core.stdout); + expect(total + 9).toBeLessThanOrEqual(10_000); + const entries = registryOf(stateDir)["e2e-sid|sessionStart||"] ?? []; + expect(entries.length).toBe(2); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/test/cursor-context.test.ts b/test/cursor-context.test.ts index 3c0f5f0..8de4cf8 100644 --- a/test/cursor-context.test.ts +++ b/test/cursor-context.test.ts @@ -110,6 +110,39 @@ test("Cursor multi-root selection canonicalizes symlinks before choosing the fil } }); +test("cursorProjectCwd: filePath in the second of two workspace roots selects the second", () => { + const roots = ["/ws/root-a", "/ws/root-b"]; + expect(cursorProjectCwd(undefined, roots, "/ws/root-b/src/app.ts", "/fallback")).toBe("/ws/root-b"); +}); + +test("cursorProjectCwd: filePath outside every workspace root falls back to the first root", () => { + const roots = ["/ws/root-a", "/ws/root-b"]; + expect(cursorProjectCwd(undefined, roots, "/elsewhere/app.ts", "/fallback")).toBe("/ws/root-a"); +}); + +test("cursorProjectCwd: empty workspaceRoots + CURSOR_PROJECT_DIR env wins over fallback", () => { + expect(cursorProjectCwd(undefined, [], undefined, "/fallback", { CURSOR_PROJECT_DIR: "/env/project" })) + .toBe("/env/project"); +}); + +test("cursorProjectCwd: CLAUDE_PROJECT_DIR used only when CURSOR_PROJECT_DIR is absent", () => { + expect(cursorProjectCwd(undefined, [], undefined, "/fallback", { CLAUDE_PROJECT_DIR: "/env/claude-project" })) + .toBe("/env/claude-project"); + expect(cursorProjectCwd(undefined, [], undefined, "/fallback", { + CURSOR_PROJECT_DIR: "/env/cursor-project", + CLAUDE_PROJECT_DIR: "/env/claude-project", + })).toBe("/env/cursor-project"); +}); + +test("cursorProjectCwd: everything absent falls back to the explicit fallback, never process.cwd()", () => { + expect(cursorProjectCwd(undefined, [], undefined, "/fallback", {})).toBe("/fallback"); +}); + +test("cursorProjectCwd: payload cwd still wins over env when present", () => { + expect(cursorProjectCwd("/payload/cwd", [], undefined, "/fallback", { CURSOR_PROJECT_DIR: "/env/project" })) + .toBe("/payload/cwd"); +}); + test("Cursor payload cwd scopes lifecycle project detection instead of process fallback", async () => { const payloadCwd = mkdtempSync(join(tmpdir(), "cursor-payload-cwd-")); const fallbackCwd = mkdtempSync(join(tmpdir(), "cursor-fallback-cwd-")); @@ -120,7 +153,7 @@ test("Cursor payload cwd scopes lifecycle project detection instead of process f conversation_id: "cursor-project-cwd", cwd: payloadCwd, workspace_roots: [payloadCwd], - }, { now: 1000, cwd: fallbackCwd }); + }, { now: 1000, cwd: fallbackCwd, home: payloadCwd }); const response = JSON.parse(outcome.stdout) as { additional_context?: string }; expect(response.additional_context).toContain("Project: Node.js"); } finally { diff --git a/test/cursor-doc-cache-gate.test.ts b/test/cursor-doc-cache-gate.test.ts new file mode 100644 index 0000000..cda4ba9 --- /dev/null +++ b/test/cursor-doc-cache-gate.test.ts @@ -0,0 +1,154 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir, homedir } from "node:os"; +import { join } from "node:path"; +import { handleHook } from "../src/runtime/handle"; +import { cacheDirFor } from "../src/runtime/lifecycle/aipilot/cache-base"; + +/** + * `dispatchAipilot` threads the REAL `homedir()` for the aipilot scope + * (`handle-scope-async.ts` never forwards `opts.home`) — same established + * precedent as `test/parity-b4-aipilot-hooks.test.ts` and + * `test/mcp-tool-name.test.ts` test 9: seed the doc cache under the real + * home, scoped by a unique per-test `CLAUDE_PROJECT_DIR` hash, and clean up. + */ +function seedDocCache(project: string, library: string): string { + const docDir = cacheDirFor("doc", project, homedir()); + mkdirSync(join(docDir, "docs"), { recursive: true }); + writeFileSync(join(docDir, "index.json"), JSON.stringify({ + docs: [{ library, hash: "cafe01", timestamp: new Date().toISOString() }], + })); + writeFileSync(join(docDir, "docs", "cafe01.md"), "cached body"); + return docDir; +} + +function withProjectDir(project: string, fn: () => Promise): Promise { + const prev = process.env.CLAUDE_PROJECT_DIR; + process.env.CLAUDE_PROJECT_DIR = project; + return fn().finally(() => { + if (prev === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = prev; + }); +} + +/** + * Positive witness (B2 decision proven end-to-end): Cursor's `preToolUse` + * `MCP:query-docs` form — server name lost, `tool_input` genuinely an + * OBJECT per ground truth — reconstructs `mcp__context7__query-docs` via + * normalize.ts's closed table, B1 projects it into `payload.tool_name` + * ahead of `asyncScopeStdout`, and `docCacheGate` (aipilot scope) denies the + * redundant call. + */ +test("Cursor preToolUse MCP:query-docs denies a cache-fresh doc call (B1 projection + B2 canonicalization)", async () => { + const project = mkdtempSync(join(tmpdir(), "fh-cursor-doccache-")); + await withProjectDir(project, async () => { + const docDir = seedDocCache(project, "react"); + try { + const payload = { + hook_event_name: "preToolUse", + session_id: "cursor-doccache-pos", + tool_name: "MCP:query-docs", + tool_input: { libraryId: "react", query: "react" }, + }; + const out = await handleHook("cursor", payload, { cwd: project, now: Date.now(), scope: "aipilot" }); + expect(JSON.parse(out.stdout).permission).toBe("deny"); + expect(out.exit).toBe(0); + } finally { + rmSync(docDir, { recursive: true, force: true }); + } + }); +}); + +/** Negative control (lesson: a probe without a matching cache fixture proves nothing). */ +test("Cursor preToolUse MCP:query-docs allows when nothing is cached (negative control)", async () => { + const project = mkdtempSync(join(tmpdir(), "fh-cursor-doccache-neg-")); + await withProjectDir(project, async () => { + const payload = { + hook_event_name: "preToolUse", + session_id: "cursor-doccache-neg", + tool_name: "MCP:query-docs", + tool_input: { libraryId: "vue", query: "vue" }, + }; + const out = await handleHook("cursor", payload, { cwd: project, now: Date.now(), scope: "aipilot" }); + expect(out).toEqual({ stdout: '{"permission":"allow"}', exit: 0 }); + }); +}); + +/** + * Now fixed: `beforeMCPExecution` (the form where Cursor DOES send + * `mcp_server_name`, `tool_input` as a JSON STRING) denies a cache-fresh doc + * call just like the `preToolUse MCP:` form above. This required two fixes + * outside this WP's original ownership, extended by the coordinator after a + * closed-literal grep proved `"BeforeMCPExecution"` is produced ONLY by + * `src/adapters/cursor/events.ts` and consumed nowhere else: + * 1. `dispatch-aipilot.ts:93` now also routes the literal lifecycle event + * `"BeforeMCPExecution"` to `docCacheGate` (previously only `"PreToolUse"`). + * 2. `handle.ts`'s `cursorRawPayloadProjection` (Cursor-only) now parses + * `payload.tool_input` when it's a JSON-string into the equivalent + * object before forwarding — `doc-cache-gate.ts` itself is untouched. + */ +test("Cursor beforeMCPExecution denies a cache-fresh doc call (dispatch-aipilot BeforeMCPExecution route + handle.ts tool_input JSON-string projection)", async () => { + const project = mkdtempSync(join(tmpdir(), "fh-cursor-doccache-mcpexec-")); + await withProjectDir(project, async () => { + const docDir = seedDocCache(project, "react"); + try { + const payload = { + hook_event_name: "beforeMCPExecution", + session_id: "cursor-doccache-mcpexec", + tool_name: "query-docs", + mcp_server_name: "context7", + tool_input: JSON.stringify({ libraryId: "react", query: "react" }), + }; + const out = await handleHook("cursor", payload, { cwd: project, now: Date.now(), scope: "aipilot" }); + expect(JSON.parse(out.stdout).permission).toBe("deny"); + expect(out.exit).toBe(0); + } finally { + rmSync(docDir, { recursive: true, force: true }); + } + }); +}); + +/** Negative control for beforeMCPExecution (no cache -> allow). */ +test("Cursor beforeMCPExecution allows when nothing is cached (negative control)", async () => { + const project = mkdtempSync(join(tmpdir(), "fh-cursor-doccache-mcpexec-neg-")); + await withProjectDir(project, async () => { + const payload = { + hook_event_name: "beforeMCPExecution", + session_id: "cursor-doccache-mcpexec-neg", + tool_name: "query-docs", + mcp_server_name: "context7", + tool_input: JSON.stringify({ libraryId: "vue", query: "vue" }), + }; + const out = await handleHook("cursor", payload, { cwd: project, now: Date.now(), scope: "aipilot" }); + expect(out).toEqual({ stdout: '{"permission":"allow"}', exit: 0 }); + }); +}); + +/** + * Structural-guard regression (challenger finding): `dispatch-aipilot.ts`'s + * `BeforeMCPExecution` route must be gated by `id === "cursor"`, NOT by the + * event-name literal alone — `asyncScopeStdout` forwards the RAW, + * unmodified `hook_event_name` for every non-Cursor id (no allowlist), so a + * claude-code/codex payload that happens to carry the literal string + * `"BeforeMCPExecution"` must NEVER reach `docCacheGate`. + */ +test("non-cursor id with a raw BeforeMCPExecution literal never reaches the doc-cache gate", async () => { + for (const id of ["claude-code", "codex"]) { + const project = mkdtempSync(join(tmpdir(), "fh-noncursor-mcpexec-")); + await withProjectDir(project, async () => { + const docDir = seedDocCache(project, "react"); + try { + const payload = { + hook_event_name: "BeforeMCPExecution", + tool_name: "mcp__context7__query-docs", + tool_input: { libraryId: "react", query: "react" }, + }; + const out = await handleHook(id, payload, { cwd: project, now: Date.now(), scope: "aipilot" }); + const parsed = out.stdout ? (JSON.parse(out.stdout) as { hookSpecificOutput?: { permissionDecision?: string } }) : {}; + expect(parsed.hookSpecificOutput?.permissionDecision).not.toBe("deny"); + } finally { + rmSync(docDir, { recursive: true, force: true }); + } + }); + } +}); diff --git a/test/cursor-mcp-provenance.test.ts b/test/cursor-mcp-provenance.test.ts index 52a9eed..ceacbdc 100644 --- a/test/cursor-mcp-provenance.test.ts +++ b/test/cursor-mcp-provenance.test.ts @@ -6,6 +6,9 @@ import { cacheLookup } from "../src/cache/store"; import { projectLayout } from "../src/config/layout"; import { handleHook } from "../src/runtime/handle"; import { normalizeEvent } from "../src/runtime/normalize"; +import { defaultStateDir, trackFile } from "../src/runtime/paths"; +import { loadTrack } from "../src/tracking/store"; +import { isDocConsulted } from "../src/freshness/doc-helpers"; test("Cursor MCP string input merges only authoritative root provenance and result fields", () => { const payload = { @@ -65,3 +68,51 @@ test("Cursor afterMCPExecution qualifies the tool and caches root result_json", expect(cacheLookup(projectLayout(cwd).cacheDir, tool, "control query", 10_000, Date.now())) .toContain("CONTROL RESULT"); }); + +/** + * B2 decision (coordinator-confirmed): Cursor's `MCP:` form (preToolUse/ + * postToolUse/postToolUseFailure — server name lost per Cursor CLI 3.18.25 + * ground truth) reconstructs the real server for the closed, gate-critical + * table in normalize.ts (`CURSOR_MCP_TOOL_SERVERS`). A tool OUTSIDE that + * table keeps its raw `MCP:` name unchanged — NO `mcp__cursor__` + * placeholder is fabricated (locked by `test/cursor-followup-normalize.test.ts`'s + * pre-existing "commandless MCP tools keep their name" contract). + */ +test("Cursor MCP: bare-tool canonicalization: closed-table servers, fuse-browser prefix rule, unknown tool stays raw", () => { + expect(normalizeEvent("cursor", { hook_event_name: "preToolUse", tool_name: "MCP:query-docs", tool_input: {} }).tool) + .toBe("mcp__context7__query-docs"); + expect(normalizeEvent("cursor", { hook_event_name: "postToolUse", tool_name: "MCP:web_search_exa", tool_input: {} }).tool) + .toBe("mcp__exa__web_search_exa"); + expect(normalizeEvent("cursor", { hook_event_name: "postToolUseFailure", tool_name: "MCP:browser_screenshot", tool_input: {} }).tool) + .toBe("mcp__fuse-browser__browser_screenshot"); + expect(normalizeEvent("cursor", { hook_event_name: "preToolUse", tool_name: "MCP:some_unlisted_tool", tool_input: {} }).tool) + .toBe("MCP:some_unlisted_tool"); +}); + +/** + * Real consumer #1: `docSourceOf` (src/runtime/activity.ts), wired through + * `handlePost` -> `activityFor` -> `recordActivity` into the session track, + * then read back by the freshness gate's `isDocConsulted` — for BOTH the + * context7 and exa servers reconstructed from Cursor's bare `MCP:` + * form. Before B2, `event.tool` stayed `"MCP:query-docs"`/`"MCP:web_search_exa"` + * and `docSourceOf` never recognized either. + */ +test("Cursor postToolUse MCP: bare tools credit doc consultation for context7 AND exa (activity.ts docSourceOf, real consumer)", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-mcp-docsource-")); + const sessionId = "cursor-mcp-docsource"; + await handleHook("cursor", { + hook_event_name: "postToolUse", + session_id: sessionId, + tool_name: "MCP:query-docs", + tool_input: { libraryId: "/facebook/react", query: "hooks" }, + }, { cwd, now: 1 }); + await handleHook("cursor", { + hook_event_name: "postToolUse", + session_id: sessionId, + tool_name: "MCP:web_search_exa", + tool_input: { query: "react hooks" }, + }, { cwd, now: 2 }); + + const track = await loadTrack(trackFile(sessionId, defaultStateDir(cwd))); + expect(isDocConsulted(track.authorizations, sessionId)).toBe(true); +}); diff --git a/test/cursor-native-bytes-cases.ts b/test/cursor-native-bytes-cases.ts new file mode 100644 index 0000000..090110e --- /dev/null +++ b/test/cursor-native-bytes-cases.ts @@ -0,0 +1,127 @@ +import { join } from "node:path"; + +/** One row of the exhaustive stdout-bytes table: event name + payload + exact expected stdout. */ +export interface ByteCase { + event: string; + payload: (cwd: string) => Record; + stdout: string; +} + +/** + * All 21 documented Cursor hook events, each on its neutral/allow path, plus + * one unknown event. Every payload avoids `.md` Read paths, `Shell`/`Bash` + * tool names, and repeated dangerous commands — the 3 branches proven (by + * direct CLI probing) to read or accumulate PERSISTENT state + * (`~/.fuse-harness/state/**`, the one-shot gate repeat counters) that would + * make a byte-exact `toBe()` flake across reruns or machines. + */ +export const CASES: ByteCase[] = [ + { + event: "beforeShellExecution", + payload: (cwd) => ({ hook_event_name: "beforeShellExecution", command: "ls -la", cwd, sandbox: false }), + stdout: '{"permission":"allow"}', + }, + { + event: "beforeMCPExecution", + payload: () => ({ + hook_event_name: "beforeMCPExecution", tool_name: "query-docs", + tool_input: "{}", mcp_server_name: "context7", + }), + stdout: '{"permission":"allow"}', + }, + { + event: "afterShellExecution", + payload: () => ({ hook_event_name: "afterShellExecution", command: "ls", output: "", duration: 1, sandbox: false }), + stdout: "{}", + }, + { + event: "afterMCPExecution", + payload: () => ({ + hook_event_name: "afterMCPExecution", tool_name: "query-docs", + tool_input: "{}", result_json: "{}", duration: 1, mcp_server_name: "context7", + }), + stdout: "{}", + }, + { + event: "beforeReadFile", + payload: (cwd) => ({ hook_event_name: "beforeReadFile", file_path: join(cwd, "notes.txt"), content: "hi", attachments: [] }), + stdout: '{"permission":"allow"}', + }, + { + event: "afterFileEdit", + payload: (cwd) => ({ + hook_event_name: "afterFileEdit", file_path: join(cwd, "notes.txt"), + edits: [{ old_string: "a", new_string: "b" }], + }), + stdout: "{}", + }, + { + event: "beforeTabFileRead", + payload: (cwd) => ({ hook_event_name: "beforeTabFileRead", file_path: join(cwd, "notes.ts"), content: "export {};" }), + stdout: '{"permission":"allow"}', + }, + { + event: "afterTabFileEdit", + payload: (cwd) => ({ + hook_event_name: "afterTabFileEdit", file_path: join(cwd, "notes.ts"), + edits: [{ old_string: "a", new_string: "b" }], + }), + stdout: "{}", + }, + { event: "stop", payload: () => ({ hook_event_name: "stop", status: "completed" }), stdout: "{}" }, + { + // No `prompt` field: keeps this on the CLAUDE.md-injection-free branch + // (userPrompt stays `undefined`), which is otherwise repo-content-dependent. + event: "beforeSubmitPrompt", + payload: () => ({ hook_event_name: "beforeSubmitPrompt", composer_mode: "agent" }), + stdout: "{}", + }, + { event: "afterAgentResponse", payload: () => ({ hook_event_name: "afterAgentResponse" }), stdout: "{}" }, + { event: "afterAgentThought", payload: () => ({ hook_event_name: "afterAgentThought" }), stdout: "{}" }, + { event: "sessionEnd", payload: () => ({ hook_event_name: "sessionEnd", reason: "user_closed_window" }), stdout: "{}" }, + { event: "preCompact", payload: () => ({ hook_event_name: "preCompact", trigger: "auto" }), stdout: "{}" }, + { + event: "subagentStart", + payload: () => ({ hook_event_name: "subagentStart", subagent_type: "explore-codebase", task: "map src/" }), + stdout: '{"permission":"allow"}', + }, + { + event: "subagentStop", + payload: () => ({ hook_event_name: "subagentStop", subagent_type: "explore-codebase", status: "completed" }), + stdout: "{}", + }, + { + event: "preToolUse", + payload: (cwd) => ({ hook_event_name: "preToolUse", tool_name: "Read", tool_input: { file_path: join(cwd, "notes.txt") } }), + stdout: '{"permission":"allow"}', + }, + { + // `Write` -> canonicalized to `Edit` (adapters/cursor/normalize.ts), which + // `activity.ts` never classifies (no doc/agent/explore/ref credit) — the + // one Edit-like tool name guaranteed inert against persisted session state. + event: "postToolUse", + payload: (cwd) => ({ + hook_event_name: "postToolUse", tool_name: "Write", + tool_input: { file_path: join(cwd, "notes.txt"), content: "hi" }, tool_output: "ok", cwd, + }), + stdout: "{}", + }, + { + event: "postToolUseFailure", + payload: (cwd) => ({ + hook_event_name: "postToolUseFailure", tool_name: "Write", + tool_input: { file_path: join(cwd, "notes.txt") }, error_message: "failed", failure_type: "tool_error", + }), + stdout: "{}", + }, + { + event: "workspaceOpen", + payload: (cwd) => ({ hook_event_name: "workspaceOpen", cursor_version: "3.18.25", workspace_roots: [cwd] }), + stdout: "{}", + }, + { + event: "futureCursorEvent (unknown)", + payload: () => ({ hook_event_name: "futureCursorEvent", command: "rm -rf /" }), + stdout: "{}", + }, +]; diff --git a/test/cursor-native-bytes.test.ts b/test/cursor-native-bytes.test.ts new file mode 100644 index 0000000..de63c65 --- /dev/null +++ b/test/cursor-native-bytes.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { bin, runtime } from "./cursor-cli-p0.test"; +import { CASES } from "./cursor-native-bytes-cases"; + +/** + * Run the Cursor CLI hook in an isolated temp `cwd` (never the repo root, so + * the harness's own side-channel writes — e.g. `.cursor/apex/docs/` + * doc-consultation notes, session track files keyed by `CLAUDE_PROJECT_DIR` + * — never land in this checkout). `HOME` is ALSO pinned to `cwd` (F1): the + * spawned child reads it fresh at process start, so any Cursor + * `additional_context` budget registry write (e.g. a sessionStart payload) + * lands under `/.fuse-harness/state/...` instead of the real + * `~/.fuse-harness/state/...` — cleaned up by the same `rmSync(cwd, ...)`. + * @param input - Raw stdin bytes fed to the CLI. + * @param cwd - Isolated temp directory used as both process cwd, project root, and HOME. + */ +function runIsolated(input: string, cwd: string): { exit: number; stdout: string } { + const child = spawnSync(runtime, [bin, "hook", "cursor", "core"], { + input, + cwd, + encoding: "utf8", + env: { ...process.env, FUSE_ENFORCE_TTL_SEC: "3600", CLAUDE_PROJECT_DIR: cwd, HOME: cwd }, + }); + return { exit: child.status ?? 1, stdout: child.stdout }; +} + +test("Cursor CLI emits the exact documented stdout bytes for every known event's neutral/allow path, plus one unknown event", () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-native-bytes-")); + try { + for (const { event, payload, stdout } of CASES) { + const result = runIsolated(JSON.stringify(payload(cwd)), cwd); + expect(result, event).toEqual({ exit: 0, stdout }); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +// `sessionStart` is EXCLUDED from the table above on purpose: its +// `additional_context` embeds this repo's own live `package.json` version and +// a git-branch/gate-history reconciliation snapshot (src/runtime/lifecycle/ +// snapshot/version.ts reads `/package.json`, unrelated to the +// event's `cwd`) — content that changes on every patch-version commit. A +// hardcoded `toBe()` would break on the very next release, so this asserts +// the stable, harness-contract-relevant shape instead — the same reasoning +// the mandate itself applies to the `rm -rf /` deny row below (prefix-only, +// because of a persisted one-shot-gate repeat counter). +test("Cursor sessionStart stays on the documented session-context shape (byte-unstable: embeds live harness version/git state)", () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-native-bytes-session-")); + try { + const result = runIsolated(JSON.stringify({ hook_event_name: "sessionStart", workspace_roots: [cwd], cwd }), cwd); + expect(result.exit).toBe(0); + const parsed = JSON.parse(result.stdout) as Record; + expect(Object.keys(parsed)).toEqual(["additional_context"]); + expect(typeof parsed.additional_context).toBe("string"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("Cursor CLI cross-cutting stdout bytes: malformed JSON, empty stdin, and native deny", () => { + const cwd = mkdtempSync(join(tmpdir(), "cursor-native-bytes-cross-")); + try { + expect(runIsolated("{not-json", cwd)).toEqual({ exit: 1, stdout: "" }); + expect(runIsolated("", cwd)).toEqual({ exit: 0, stdout: "{}" }); + // The reason text carries a persisted, ever-incrementing one-shot-gate + // repeat counter ("[REPEAT] ... attempt #N") — the mandate scopes this + // row to the stable prefix, not the full byte string, for that reason. + const denied = runIsolated( + JSON.stringify({ hook_event_name: "preToolUse", tool_name: "Shell", tool_input: { command: "rm -rf /", cwd } }), + cwd, + ); + expect(denied.exit).toBe(0); + expect(denied.stdout.startsWith('{"permission":"deny",')).toBe(true); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("Cursor native-bytes table covers exactly the 21 documented events plus one unknown event", () => { + expect(CASES.length).toBe(21); + expect(new Set(CASES.map((c) => c.event)).size).toBe(21); +}); diff --git a/test/cursor-native-routing.test.ts b/test/cursor-native-routing.test.ts index ed3a853..667e565 100644 --- a/test/cursor-native-routing.test.ts +++ b/test/cursor-native-routing.test.ts @@ -110,7 +110,7 @@ test("Cursor sessionStart converts lifecycle context to its native envelope", as try { const outcome = await handleHook("cursor", { hook_event_name: "sessionStart", conversation_id: "cursor-session-start", workspace_roots: [cwd], cwd, - }, { now: 1000, cwd }); + }, { now: 1000, cwd, home: cwd }); const response = JSON.parse(outcome.stdout) as Record; expect(typeof response.additional_context).toBe("string"); expect(response).not.toHaveProperty("hookSpecificOutput"); diff --git a/test/cursor-plugin-root.test.ts b/test/cursor-plugin-root.test.ts new file mode 100644 index 0000000..8edcc3d --- /dev/null +++ b/test/cursor-plugin-root.test.ts @@ -0,0 +1,98 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveCursorPluginRoot } from "../src/adapters/cursor/plugin-root"; + +const scratch = (prefix: string): string => mkdtempSync(join(tmpdir(), prefix)); + +/** Write a `.cursor-plugin/plugin.json` marker into `dir`. */ +function markLocalPlugin(dir: string): void { + mkdirSync(join(dir, ".cursor-plugin"), { recursive: true }); + writeFileSync(join(dir, ".cursor-plugin", "plugin.json"), "{}"); +} + +test("resolveCursorPluginRoot: CURSOR_PLUGIN_ROOT env wins over everything", () => { + const dir = scratch("fh-cursor-plugin-"); + const result = resolveCursorPluginRoot({ CURSOR_PLUGIN_ROOT: dir, CLAUDE_PLUGIN_ROOT: "/other" }, "/unrelated/cwd"); + expect(result.source).toBe("env:CURSOR_PLUGIN_ROOT"); + expect(result.root).toBe(realpathSync.native(dir)); +}); + +test("resolveCursorPluginRoot: CLAUDE_PLUGIN_ROOT env wins when CURSOR_PLUGIN_ROOT is absent", () => { + const dir = scratch("fh-cursor-plugin-"); + const result = resolveCursorPluginRoot({ CLAUDE_PLUGIN_ROOT: dir }, "/unrelated/cwd"); + expect(result.source).toBe("env:CLAUDE_PLUGIN_ROOT"); + expect(result.root).toBe(realpathSync.native(dir)); +}); + +test("resolveCursorPluginRoot: local plugin install detected via cwd marker, env absent", () => { + const dir = scratch("fh-cursor-plugin-local-"); + markLocalPlugin(dir); + const result = resolveCursorPluginRoot({}, dir); + expect(result.source).toBe("cwd:plugin-marker"); + expect(result.root).toBe(realpathSync.native(dir)); +}); + +test("resolveCursorPluginRoot: simulated marketplace cache layout detected via cwd marker", () => { + const home = scratch("fh-cursor-home-"); + const cacheLeaf = join(home, ".cursor", "plugins", "cache", "fusengine-plugins", "fuse-typescript", "1.0.4"); + mkdirSync(join(cacheLeaf, "hooks"), { recursive: true }); + writeFileSync(join(cacheLeaf, "hooks", "hooks.json"), "{}"); + const result = resolveCursorPluginRoot({}, cacheLeaf); + expect(result.source).toBe("cwd:plugin-marker"); + expect(result.root).toBe(realpathSync.native(cacheLeaf)); +}); + +test("resolveCursorPluginRoot: env value resolves through a symlink to its realpath", () => { + const base = scratch("fh-cursor-symlink-"); + const actual = join(base, "actual-plugin"); + mkdirSync(actual, { recursive: true }); + const alias = join(base, "alias-plugin"); + symlinkSync(actual, alias); + const result = resolveCursorPluginRoot({ CURSOR_PLUGIN_ROOT: alias }, "/cwd"); + expect(result.source).toBe("env:CURSOR_PLUGIN_ROOT"); + expect(result.root).toBe(realpathSync.native(actual)); +}); + +test("resolveCursorPluginRoot: env path with spaces is accepted", () => { + const base = scratch("fh-cursor-spaces-"); + const dir = join(base, "my plugin root"); + mkdirSync(dir, { recursive: true }); + const result = resolveCursorPluginRoot({ CURSOR_PLUGIN_ROOT: dir }, "/cwd"); + expect(result.source).toBe("env:CURSOR_PLUGIN_ROOT"); + expect(result.root).toBe(realpathSync.native(dir)); +}); + +test("resolveCursorPluginRoot: env absent, cwd unmarked -> none, with checked reasons", () => { + const dir = scratch("fh-cursor-unmarked-"); + const result = resolveCursorPluginRoot({}, dir); + expect(result.source).toBe("none"); + expect(result.root).toBeNull(); + expect(result.checked.length).toBeGreaterThan(0); + expect(result.checked.some((c) => c.includes("CURSOR_PLUGIN_ROOT"))).toBe(true); + expect(result.checked.some((c) => c.includes("CLAUDE_PLUGIN_ROOT"))).toBe(true); +}); + +test("resolveCursorPluginRoot: env pointing to a non-existent path is ignored, falls through to next candidate", () => { + const validDir = scratch("fh-cursor-fallthrough-"); + const result = resolveCursorPluginRoot( + { CURSOR_PLUGIN_ROOT: "/definitely/does/not/exist/anywhere", CLAUDE_PLUGIN_ROOT: validDir }, + "/cwd", + ); + expect(result.source).toBe("env:CLAUDE_PLUGIN_ROOT"); + expect(result.root).toBe(realpathSync.native(validDir)); + expect(result.checked.some((c) => c.includes("CURSOR_PLUGIN_ROOT"))).toBe(true); +}); + +test("resolveCursorPluginRoot: cwd outside the project (npm/npx global bin) with env present -> env wins", () => { + const dir = scratch("fh-cursor-plugin-npx-"); + const result = resolveCursorPluginRoot({ CURSOR_PLUGIN_ROOT: dir }, "/usr/local/lib/node_modules/npm/bin"); + expect(result.source).toBe("env:CURSOR_PLUGIN_ROOT"); + expect(result.root).toBe(realpathSync.native(dir)); +}); + +test("resolveCursorPluginRoot: empty-string env var is treated as unset, not an invalid candidate crash", () => { + const result = resolveCursorPluginRoot({ CURSOR_PLUGIN_ROOT: "" }, "/cwd"); + expect(result.source).toBe("none"); +}); diff --git a/test/cursor-raw-payload-projection.test.ts b/test/cursor-raw-payload-projection.test.ts new file mode 100644 index 0000000..6d29035 --- /dev/null +++ b/test/cursor-raw-payload-projection.test.ts @@ -0,0 +1,146 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleHook } from "../src/runtime/handle"; +import { normalizeEvent } from "../src/runtime/normalize"; +import { failureLessonContext } from "../src/runtime/lifecycle/failure-lesson"; +import { trackAgentMemory } from "../src/runtime/lifecycle/agent-memory"; +import { cursorProjectCwd } from "../src/adapters/cursor/context"; +import { saveSessionState } from "../src/runtime/home-state"; +import { loadState, SIDECAR } from "../src/tracking/one-shot"; +import { defaultStateDir } from "../src/runtime/paths"; + +/** + * B1 (`handle.ts`'s `cursorRawPayloadProjection`) proven at three safe, + * hermetic layers: + * - memory + seo scopes: full `handleHook` end-to-end (no real-home writes + * on THOSE two code paths — `dispatchMemory`/`seoPostToolUseResponse` + * never touch `homedir()`). + * - failure-lesson / agent-memory: `dispatch.ts`'s `dispatchLifecycle` + * (out of this WP's ownership) hardcodes `home: undefined` when wiring + * `failureLessonContext`/`trackAgentMemory` for BOTH `PostToolUseFailure` + * and `SubagentStop` — going through the full pipeline for these two + * events would append to the REAL `~/.claude/logs/tool-failures.log` + * (hard-stop: never write to the real `~/.claude`). So — matching this + * repo's own convention (`test/failure-lesson.test.ts`, + * `test/agent-memory-stop.test.ts`, both call the handler directly with + * an injected tmp `home`) — these two are exercised directly, fed the + * EXACT payload shape `cursorRawPayloadProjection` produces (its + * `tool_name`/`cwd` values are obtained from the REAL `normalizeEvent`/ + * `cursorProjectCwd` functions, never hand-typed). + */ + +// --- memory scope: dispatchMemory sees the canonical tool (real end-to-end) --- + +test("Cursor postToolUse 'Shell' (raw) + command canonicalizes to 'Bash' and reaches dispatchMemory's captureBashError branch", async () => { + const cwd = mkdtempSync(join(tmpdir(), "fh-raw-memory-")); + const payload = { + hook_event_name: "postToolUse", + session_id: "cursor-raw-memory", + tool_name: "Shell", + command: "false", + tool_input: { command: "false" }, + tool_result: { exit_code: 1, stderr: "error: boom" }, + }; + const out = await handleHook("cursor", payload, { cwd, now: 1, scope: "memory", home: cwd }); + // dispatchMemory's `tool === "Bash"` branch was reached (captureBashError + // returns a non-empty additionalContext when severity clears the salience + // threshold) — impossible before B1, since raw "Shell" never equals "Bash". + expect(out.stdout.length).toBeGreaterThan(0); + expect(out.stdout).toContain("qdrant"); +}); + +test("non-regression: the SAME 'Shell'+command payload on claude-code/codex never gets rewritten to 'Bash' (memory scope stays a no-op)", async () => { + for (const id of ["claude-code", "codex"]) { + const cwd = mkdtempSync(join(tmpdir(), "fh-raw-memory-noreg-")); + const payload = { + hook_event_name: "PostToolUse", + session_id: "noreg-memory", + tool_name: "Shell", + tool_input: {}, + tool_result: { exit_code: 1, stderr: "error: boom" }, + }; + expect(normalizeEvent(id, payload).tool).toBe("Shell"); + const out = await handleHook(id, payload, { cwd, now: 1, scope: "memory" }); + expect(out.stdout).not.toContain("qdrant"); + } +}); + +// --- seo scope: post-tool-use.ts sees the resolved project cwd, not dirname(path) --- + +test("Cursor postToolUse (seo scope): post-tool-use.ts resolves the SEO marker from the projected project cwd, not dirname(file_path)", async () => { + const projectRoot = mkdtempSync(join(tmpdir(), "fh-raw-seo-project-")); + writeFileSync(join(projectRoot, ".fuse-seo"), ""); + const elsewhere = mkdtempSync(join(tmpdir(), "fh-raw-seo-elsewhere-")); + const htmlFile = join(elsewhere, "page.html"); + writeFileSync(htmlFile, "no seo tags here"); + + const payload = { + hook_event_name: "postToolUse", + session_id: "cursor-raw-seo", + tool_name: "Edit", + tool_input: { file_path: htmlFile }, + workspace_roots: [projectRoot], + }; + // opts.cwd deliberately wrong (neither projectRoot nor elsewhere) — the fix + // must resolve the REAL project root from workspace_roots, not this value. + const wrongFallback = mkdtempSync(join(tmpdir(), "fh-raw-seo-fallback-")); + const out = await handleHook("cursor", payload, { cwd: wrongFallback, now: 1, scope: "seo", home: wrongFallback }); + expect(out.stdout).toContain("fuse-seo: missing SEO elements"); +}); + +test("non-regression: the SAME cross-directory seo payload on claude-code never gains a project-cwd it wasn't given", async () => { + const projectRoot = mkdtempSync(join(tmpdir(), "fh-raw-seo-project2-")); + writeFileSync(join(projectRoot, ".fuse-seo"), ""); + const elsewhere = mkdtempSync(join(tmpdir(), "fh-raw-seo-elsewhere2-")); + const htmlFile = join(elsewhere, "page.html"); + writeFileSync(htmlFile, "no seo tags here"); + + const payload = { + hook_event_name: "PostToolUse", + session_id: "noreg-seo", + tool_name: "Edit", + tool_input: { file_path: htmlFile }, + }; + const wrongFallback = mkdtempSync(join(tmpdir(), "fh-raw-seo-fallback2-")); + const out = await handleHook("claude-code", payload, { cwd: wrongFallback, now: 1, scope: "seo" }); + // claude-code payload carries no `cwd`/`workspace_roots` either: falls back + // to dirname(htmlFile) = `elsewhere`, which has no `.fuse-seo` marker. + expect(out.stdout).not.toContain("fuse-seo:"); +}); + +// --- failure-lesson: data.tool_name arrives canonical ("Bash", not "Shell") --- + +test("Cursor postToolUseFailure 'Shell'+command canonicalizes to 'Bash' before failure-lesson reads it (payload shape B1 produces)", () => { + const cwd = mkdtempSync(join(tmpdir(), "fh-raw-faillesson-")); + const home = mkdtempSync(join(tmpdir(), "fh-raw-faillesson-home-")); + const rawPayload = { hook_event_name: "postToolUseFailure", tool_name: "Shell", command: "false", session_id: "s1", error: "boom" }; + const canonicalTool = normalizeEvent("cursor", rawPayload).tool; + expect(canonicalTool).toBe("Bash"); + // The exact shape cursorRawPayloadProjection builds: tool_name overridden to + // event.tool, original preserved under cursor_tool_name. + const projected = { ...rawPayload, cursor_tool_name: rawPayload.tool_name, tool_name: canonicalTool }; + failureLessonContext(projected, cwd, home, 1000, () => true); + const state = loadState(join(defaultStateDir(cwd), SIDECAR)); + expect(state.failures?.Bash).toBe(1); + expect(state.failures?.Shell).toBeUndefined(); +}); + +// --- agent-memory: data.cwd arrives as the resolved project root, not process.cwd() --- + +test("Cursor subagentStop with workspace_roots (no 'cwd' field) resolves to the project root via cursorProjectCwd (payload shape B1 produces)", () => { + const home = mkdtempSync(join(tmpdir(), "fh-raw-agentmem-home-")); + const projectRoot = mkdtempSync(join(tmpdir(), "fh-raw-agentmem-project-")); + const touched = join(projectRoot, "touched.ts"); + writeFileSync(touched, "export const x = 1;\n"); + saveSessionState("s-agentmem", { changes: { cumulativeCodeFiles: 1, modifiedFiles: ["touched.ts"] } }, home); + + // Ground truth: Cursor's subagentStop payload has NO `cwd` field, only + // `workspace_roots` — mirrors cursorProjectCwd's own resolution order. + const resolvedCwd = cursorProjectCwd(undefined, [projectRoot], undefined, "/should-not-be-used"); + expect(resolvedCwd).toBe(projectRoot); + + const out = trackAgentMemory({ agent_type: "react-expert", session_id: "s-agentmem", cwd: resolvedCwd }, home, 1000); + expect(out).toContain("modified 1 code file(s): touched.ts"); +}); diff --git a/test/cursor-response-channels.test.ts b/test/cursor-response-channels.test.ts index 61fbc58..b2fdf60 100644 --- a/test/cursor-response-channels.test.ts +++ b/test/cursor-response-channels.test.ts @@ -1,6 +1,44 @@ import { expect, test } from "bun:test"; import { toCursorLifecycleResponse, toCursorResponse } from "../src/adapters/cursor/respond"; +const TRUNCATION_MARKER = "\n[fuse-harness] additional_context truncated to Cursor's 10000-char limit"; + +/** Minimal valid native passthrough for one event, carrying a given `additional_context` length. */ +function nativeWithContext(eventName: string, length: number): string { + const additional_context = "x".repeat(length); + const byEvent: Record> = { + sessionStart: { additional_context }, + beforeSubmitPrompt: { continue: true, additional_context }, + preToolUse: { permission: "allow", additional_context }, + postToolUse: { additional_context }, + postToolUseFailure: { additional_context }, + }; + return JSON.stringify(byEvent[eventName]); +} + +test("Cursor additional_context truncates to Cursor's 10000-char carrier limit on every affected event", () => { + for (const eventName of ["sessionStart", "beforeSubmitPrompt", "preToolUse", "postToolUse", "postToolUseFailure"]) { + const oversized = nativeWithContext(eventName, 12_000); + const truncated = JSON.parse(toCursorLifecycleResponse(oversized, eventName)) as { additional_context: string }; + expect(truncated.additional_context.length, eventName).toBeLessThanOrEqual(10_000); + expect(truncated.additional_context.endsWith(TRUNCATION_MARKER), eventName).toBe(true); + + const under = nativeWithContext(eventName, 9_000); + expect(toCursorLifecycleResponse(under, eventName), eventName).toBe(under); + + const exact = nativeWithContext(eventName, 10_000); + expect(toCursorLifecycleResponse(exact, eventName), eventName).toBe(exact); + } +}); + +test("Cursor direct response additional_context also truncates at Cursor's 10000-char limit", () => { + const reason = "x".repeat(12_000 - 8); + const rendered = toCursorResponse({ kind: "inform", title: "", reason }, "sessionStart"); + const { additional_context } = JSON.parse(rendered) as { additional_context: string }; + expect(additional_context.length).toBeLessThanOrEqual(10_000); + expect(additional_context.endsWith(TRUNCATION_MARKER)).toBe(true); +}); + test("Cursor direct inform preserves distinct user and agent channels", () => { expect(toCursorResponse({ kind: "inform", diff --git a/test/cursor-runtime-native-boundary.test.ts b/test/cursor-runtime-native-boundary.test.ts index 8b02bb5..95d24f4 100644 --- a/test/cursor-runtime-native-boundary.test.ts +++ b/test/cursor-runtime-native-boundary.test.ts @@ -13,7 +13,7 @@ test("Cursor security advisory crosses the runtime boundary as native agent cont conversation_id: "cursor-security", tool_name: "Write", tool_input: { file_path: join(cwd, "app.ts"), content: "export {};" }, - }, { now: 1, cwd, scope: "security" }); + }, { now: 1, cwd, scope: "security", home: cwd }); expect(out).toEqual({ stdout: expect.stringContaining('"permission":"allow"'), exit: 0, @@ -33,7 +33,7 @@ test("Cursor solid deny crosses the runtime boundary as native permission", asyn conversation_id: "cursor-solid", tool_name: "Write", tool_input: { file_path: join(cwd, "store.go"), content: "type Store interface {\n}\n" }, - }, { now: 1, cwd, scope: "solid" }); + }, { now: 1, cwd, scope: "solid", home: cwd }); const parsed = JSON.parse(out.stdout) as Record; expect(parsed.permission).toBe("deny"); expect(parsed.agent_message).toContain("internal/interfaces/"); @@ -56,7 +56,7 @@ test("Cursor scoped postToolUse crosses the runtime boundary as native post cont tool_input: { file_path: file, content: "" }, tool_output: "ok", cwd, - }, { now: 1, cwd, scope: "seo" }); + }, { now: 1, cwd, scope: "seo", home: cwd }); const parsed = JSON.parse(out.stdout) as Record; expect(parsed.additional_context).toContain("missing SEO elements"); expect(parsed).not.toHaveProperty("decision"); diff --git a/test/fixtures/cursor/README.md b/test/fixtures/cursor/README.md new file mode 100644 index 0000000..6eaf922 --- /dev/null +++ b/test/fixtures/cursor/README.md @@ -0,0 +1,64 @@ +# Cursor hook fixtures + +Fixtures for `test/cursor-authentic-fixtures.test.ts` and `test/cursor-native-bytes.test.ts`, +one directory per Cursor hook event name. Each fixture is a JSON file with two top-level +keys: `provenance` (where the payload came from) and `stdin` (the exact bytes fed to the +harness's Cursor adapter). + +## Two kinds of fixture + +### 1. Authentic captures (8 files, 3 events) + +Real Cursor stdin payloads, captured from `~/Library/Application Support/Cursor/logs/cursor.hooks*.log` +on Cursor 3.17.8 / 3.18.9 (2026-08-23 to 2026-09-01), via a probe hook that logged its own stdin +and wrote no output. Copied **as-is** (byte-identical `stdin`) from the sanitized corpus at +`scratchpad/cursor-captures/fixtures/` — see that directory's `field-matrix.md`, `diagnostics.md`, +and `gaps.md` for the full analysis this fixture set is drawn from. + +| Directory | Files | Cursor version | +|---|---|---| +| `sessionStart/` | 1 | 3.17.8 (empty window, Claude-compat `claude-user config`) | +| `beforeSubmitPrompt/` | 1 | 3.18.9 | +| `preToolUse/` | 6 (`01`–`06`) | 3.18.9 (tools: Task, Shell, Write, Grep, Read) | + +**Sanitization method (v1)**, applied to the raw logs before any fixture was written: +- Real home directories → `/Users/user` +- Real project paths → `/Users/user/project` +- Real UUIDs (conversation/generation/session/tool-call ids) → deterministic fake UUIDs, + internally consistent (the same real UUID always maps to the same fake one within a + capture, preserving e.g. `conversation_id === session_id`) +- Real email → `user@example.com` +- Free-text tool inputs (prompts, file contents) → `` placeholders that + preserve the original length +- Everything else (field names, types, presence/absence, numeric values, key order) is + UNCHANGED from the real capture + +### 2. Binary-verified synthetic shapes (14 files, `preToolUse/07` + 14 event directories) + +For every mandate event with **no authentic capture available** (see "no capture" list +below), the fixture's `stdin` shape is NOT a live capture. It was constructed field-by-field +from Cursor 3.18.25's own validators (`agent-cli 190.index.js` / `workbench.desktop.main.js`, +functions `R`/`Ded`), cross-checked against the published Cursor hooks documentation. Every +such fixture carries: + +```json +"provenance": { "source": "binary-verified shape (Cursor 3.18.25, agent-cli 190.index.js / workbench.desktop.main.js) — NOT a live capture" } +``` + +`preToolUse/07-multi-root-synthetic.json` is additionally synthetic within an otherwise +authentic-backed event: `workspace_roots` with 2 entries was never observed in the corpus +(`gaps.md`: "multi-root NOT observed"), so that one field is a manual augmentation of the +otherwise-real `preToolUse` shape. + +## AUCUNE CAPTURE AUTHENTIQUE DISPONIBLE (14 events) + +The following 14 mandate events have zero authentic stdin capture on this machine (0 `Hook +step requested: ` log lines with a matching input/output block, or a step that fired +with no hook attached to log a payload). Their fixtures are entirely binary-verified/synthetic: + +`sessionEnd`, `preCompact`, `subagentStart`, `subagentStop`, `postToolUse`, +`postToolUseFailure`, `beforeShellExecution`, `afterShellExecution`, `beforeMCPExecution`, +`afterMCPExecution`, `beforeReadFile`, `afterFileEdit`, `stop`, `workspaceOpen`. + +`workspaceOpen` fired 166 times in the logs but never with a configured hook, so even its +step-requested line carries no payload — 0/166 gave a capturable body. diff --git a/test/fixtures/cursor/afterFileEdit/01-synthetic.json b/test/fixtures/cursor/afterFileEdit/01-synthetic.json new file mode 100644 index 0000000..cd7e538 --- /dev/null +++ b/test/fixtures/cursor/afterFileEdit/01-synthetic.json @@ -0,0 +1,20 @@ +{ + "provenance": { + "source": "binary-verified shape (Cursor 3.18.25, agent-cli 190.index.js / workbench.desktop.main.js) — NOT a live capture" + }, + "stdin": { + "conversation_id": "00000000-0000-4000-8000-000000000601", + "generation_id": "00000000-0000-4000-8000-000000000602", + "model": "cursor-grok-4.6-medium", + "file_path": "/Users/user/project/src/index.ts", + "edits": [ + { "old_string": "const value = 1;", "new_string": "const value = 2;" } + ], + "hook_event_name": "afterFileEdit", + "cursor_version": "3.18.25", + "workspace_roots": ["/Users/user/project"], + "user_email": "user@example.com", + "transcript_path": "/Users/user/.cursor/projects/Users-user-project/agent-transcripts/00000000-0000-4000-8000-000000000601/00000000-0000-4000-8000-000000000601.jsonl", + "session_id": "00000000-0000-4000-8000-000000000601" + } +} diff --git a/test/fixtures/cursor/afterMCPExecution/01-synthetic.json b/test/fixtures/cursor/afterMCPExecution/01-synthetic.json new file mode 100644 index 0000000..b5d4a67 --- /dev/null +++ b/test/fixtures/cursor/afterMCPExecution/01-synthetic.json @@ -0,0 +1,21 @@ +{ + "provenance": { + "source": "binary-verified shape (Cursor 3.18.25, agent-cli 190.index.js / workbench.desktop.main.js) — NOT a live capture" + }, + "stdin": { + "conversation_id": "00000000-0000-4000-8000-000000000401", + "generation_id": "00000000-0000-4000-8000-000000000402", + "model": "cursor-grok-4.6-medium", + "tool_name": "query-docs", + "tool_input": "{\"libraryId\":\"/vercel/next.js\",\"query\":\"routing\"}", + "result_json": "{\"content\":[{\"type\":\"text\",\"text\":\"...\"}]}", + "duration": 812, + "mcp_server_name": "context7", + "hook_event_name": "afterMCPExecution", + "cursor_version": "3.18.25", + "workspace_roots": ["/Users/user/project"], + "user_email": "user@example.com", + "transcript_path": "/Users/user/.cursor/projects/Users-user-project/agent-transcripts/00000000-0000-4000-8000-000000000401/00000000-0000-4000-8000-000000000401.jsonl", + "session_id": "00000000-0000-4000-8000-000000000401" + } +} diff --git a/test/fixtures/cursor/afterShellExecution/01-synthetic.json b/test/fixtures/cursor/afterShellExecution/01-synthetic.json new file mode 100644 index 0000000..d35586d --- /dev/null +++ b/test/fixtures/cursor/afterShellExecution/01-synthetic.json @@ -0,0 +1,20 @@ +{ + "provenance": { + "source": "binary-verified shape (Cursor 3.18.25, agent-cli 190.index.js / workbench.desktop.main.js) — NOT a live capture" + }, + "stdin": { + "conversation_id": "00000000-0000-4000-8000-000000000201", + "generation_id": "00000000-0000-4000-8000-000000000202", + "model": "cursor-grok-4.6-medium", + "command": "ls -la", + "output": "total 0\ndrwxr-xr-x 2 user staff 64 Jan 1 00:00 .\n", + "duration": 42, + "sandbox": false, + "hook_event_name": "afterShellExecution", + "cursor_version": "3.18.25", + "workspace_roots": ["/Users/user/project"], + "user_email": "user@example.com", + "transcript_path": "/Users/user/.cursor/projects/Users-user-project/agent-transcripts/00000000-0000-4000-8000-000000000201/00000000-0000-4000-8000-000000000201.jsonl", + "session_id": "00000000-0000-4000-8000-000000000201" + } +} diff --git a/test/fixtures/cursor/beforeMCPExecution/01-synthetic.json b/test/fixtures/cursor/beforeMCPExecution/01-synthetic.json new file mode 100644 index 0000000..06ea197 --- /dev/null +++ b/test/fixtures/cursor/beforeMCPExecution/01-synthetic.json @@ -0,0 +1,21 @@ +{ + "provenance": { + "source": "binary-verified shape (Cursor 3.18.25, agent-cli 190.index.js / workbench.desktop.main.js) — NOT a live capture" + }, + "stdin": { + "conversation_id": "00000000-0000-4000-8000-000000000301", + "generation_id": "00000000-0000-4000-8000-000000000302", + "model": "cursor-grok-4.6-medium", + "tool_name": "query-docs", + "tool_input": "{\"libraryId\":\"/vercel/next.js\",\"query\":\"routing\"}", + "mcp_server_name": "context7", + "url": "https://mcp.context7.com", + "mcp_server_url": "https://mcp.context7.com", + "hook_event_name": "beforeMCPExecution", + "cursor_version": "3.18.25", + "workspace_roots": ["/Users/user/project"], + "user_email": "user@example.com", + "transcript_path": "/Users/user/.cursor/projects/Users-user-project/agent-transcripts/00000000-0000-4000-8000-000000000301/00000000-0000-4000-8000-000000000301.jsonl", + "session_id": "00000000-0000-4000-8000-000000000301" + } +} diff --git a/test/fixtures/cursor/beforeReadFile/01-synthetic.json b/test/fixtures/cursor/beforeReadFile/01-synthetic.json new file mode 100644 index 0000000..1f2d126 --- /dev/null +++ b/test/fixtures/cursor/beforeReadFile/01-synthetic.json @@ -0,0 +1,19 @@ +{ + "provenance": { + "source": "binary-verified shape (Cursor 3.18.25, agent-cli 190.index.js / workbench.desktop.main.js) — NOT a live capture" + }, + "stdin": { + "conversation_id": "00000000-0000-4000-8000-000000000501", + "generation_id": "00000000-0000-4000-8000-000000000502", + "model": "cursor-grok-4.6-medium", + "content": "export {};\n", + "file_path": "/Users/user/project/src/index.ts", + "attachments": [], + "hook_event_name": "beforeReadFile", + "cursor_version": "3.18.25", + "workspace_roots": ["/Users/user/project"], + "user_email": "user@example.com", + "transcript_path": "/Users/user/.cursor/projects/Users-user-project/agent-transcripts/00000000-0000-4000-8000-000000000501/00000000-0000-4000-8000-000000000501.jsonl", + "session_id": "00000000-0000-4000-8000-000000000501" + } +} diff --git a/test/fixtures/cursor/beforeShellExecution/01-synthetic.json b/test/fixtures/cursor/beforeShellExecution/01-synthetic.json new file mode 100644 index 0000000..77e042f --- /dev/null +++ b/test/fixtures/cursor/beforeShellExecution/01-synthetic.json @@ -0,0 +1,19 @@ +{ + "provenance": { + "source": "binary-verified shape (Cursor 3.18.25, agent-cli 190.index.js / workbench.desktop.main.js) — NOT a live capture" + }, + "stdin": { + "conversation_id": "00000000-0000-4000-8000-000000000101", + "generation_id": "00000000-0000-4000-8000-000000000102", + "model": "cursor-grok-4.6-medium", + "command": "ls -la", + "cwd": "/Users/user/project", + "sandbox": false, + "hook_event_name": "beforeShellExecution", + "cursor_version": "3.18.25", + "workspace_roots": ["/Users/user/project"], + "user_email": "user@example.com", + "transcript_path": "/Users/user/.cursor/projects/Users-user-project/agent-transcripts/00000000-0000-4000-8000-000000000101/00000000-0000-4000-8000-000000000101.jsonl", + "session_id": "00000000-0000-4000-8000-000000000101" + } +} diff --git a/test/fixtures/cursor/beforeSubmitPrompt/01-agent-mode-no-attachments.json b/test/fixtures/cursor/beforeSubmitPrompt/01-agent-mode-no-attachments.json new file mode 100644 index 0000000..2498fdb --- /dev/null +++ b/test/fixtures/cursor/beforeSubmitPrompt/01-agent-mode-no-attachments.json @@ -0,0 +1,45 @@ +{ + "provenance": { + "source": "cursor.hooks log", + "cursor_version": "3.18.9", + "log": "20260901T164203/window1_wb2/output_20260901T164220/cursor.hooks.workspaceId-7836681c53300201c3d1e17a4405c32b.log", + "captured_at": "2026-09-01T15:09:48.960Z", + "hook_command": "/private/tmp/claude-501/-Users-user-Labo-docker-lab-dev-local-Dev-ai-claude-code-claude-plugins/70aefd0e-de58-5623-b9a0-22e958c9331b/scratchpad/cursor-hook-probe.sh beforeSubmitPrompt", + "config_source": "user config", + "sanitization": "v1" + }, + "stdin": { + "conversation_id": "b3f17b66-e4ba-5fd0-8f3b-dc5b32fc4f8c", + "generation_id": "21d4a1c8-b010-5b28-a58c-0c16eb56cd82", + "model": "cursor-grok-4.6-medium", + "model_id": "grok-4.6", + "model_params": [ + { + "id": "effort", + "value": "medium" + }, + { + "id": "fast", + "value": "false" + } + ], + "composer_mode": "agent", + "prompt": "", + "attachments": [], + "session_id": "b3f17b66-e4ba-5fd0-8f3b-dc5b32fc4f8c", + "hook_event_name": "beforeSubmitPrompt", + "cursor_version": "3.18.9", + "workspace_roots": [ + "/Users/user/project" + ], + "user_email": "user@example.com", + "transcript_path": "/Users/user/.cursor/projects/Users-user-project/agent-transcripts/b3f17b66-e4ba-5fd0-8f3b-dc5b32fc4f8c/b3f17b66-e4ba-5fd0-8f3b-dc5b32fc4f8c.jsonl" + }, + "observed_output": "(empty)", + "observed_exit": 0, + "observed_duration_ms": 71, + "cursor_reaction": [ + "Hook 1 produced no output", + "All hooks for step beforeSubmitPrompt completed but none returned a valid response" + ] +} \ No newline at end of file diff --git a/test/fixtures/cursor/postToolUse/01-synthetic.json b/test/fixtures/cursor/postToolUse/01-synthetic.json new file mode 100644 index 0000000..90e838f --- /dev/null +++ b/test/fixtures/cursor/postToolUse/01-synthetic.json @@ -0,0 +1,22 @@ +{ + "provenance": { + "source": "binary-verified shape (Cursor 3.18.25, agent-cli 190.index.js / workbench.desktop.main.js) — NOT a live capture" + }, + "stdin": { + "conversation_id": "00000000-0000-4000-8000-000000000701", + "generation_id": "00000000-0000-4000-8000-000000000702", + "model": "cursor-grok-4.6-medium", + "tool_name": "Read", + "tool_input": { "file_path": "/Users/user/project/src/index.ts" }, + "tool_output": "export {};\n", + "duration": 12, + "tool_use_id": "00000000-0000-4000-8000-000000000703", + "cwd": "/Users/user/project", + "hook_event_name": "postToolUse", + "cursor_version": "3.18.25", + "workspace_roots": ["/Users/user/project"], + "user_email": "user@example.com", + "transcript_path": "/Users/user/.cursor/projects/Users-user-project/agent-transcripts/00000000-0000-4000-8000-000000000701/00000000-0000-4000-8000-000000000701.jsonl", + "session_id": "00000000-0000-4000-8000-000000000701" + } +} diff --git a/test/fixtures/cursor/postToolUseFailure/01-synthetic.json b/test/fixtures/cursor/postToolUseFailure/01-synthetic.json new file mode 100644 index 0000000..5984068 --- /dev/null +++ b/test/fixtures/cursor/postToolUseFailure/01-synthetic.json @@ -0,0 +1,23 @@ +{ + "provenance": { + "source": "binary-verified shape (Cursor 3.18.25, agent-cli 190.index.js / workbench.desktop.main.js) — NOT a live capture" + }, + "stdin": { + "conversation_id": "00000000-0000-4000-8000-000000000801", + "generation_id": "00000000-0000-4000-8000-000000000802", + "model": "cursor-grok-4.6-medium", + "tool_name": "Shell", + "tool_input": { "command": "false", "cwd": "" }, + "error_message": "command exited with status 1", + "failure_type": "tool_error", + "duration": 5, + "tool_use_id": "00000000-0000-4000-8000-000000000803", + "is_interrupt": false, + "hook_event_name": "postToolUseFailure", + "cursor_version": "3.18.25", + "workspace_roots": ["/Users/user/project"], + "user_email": "user@example.com", + "transcript_path": "/Users/user/.cursor/projects/Users-user-project/agent-transcripts/00000000-0000-4000-8000-000000000801/00000000-0000-4000-8000-000000000801.jsonl", + "session_id": "00000000-0000-4000-8000-000000000801" + } +} diff --git a/test/fixtures/cursor/preCompact/01-synthetic.json b/test/fixtures/cursor/preCompact/01-synthetic.json new file mode 100644 index 0000000..7f4e04a --- /dev/null +++ b/test/fixtures/cursor/preCompact/01-synthetic.json @@ -0,0 +1,23 @@ +{ + "provenance": { + "source": "binary-verified shape (Cursor 3.18.25, agent-cli 190.index.js / workbench.desktop.main.js) — NOT a live capture" + }, + "stdin": { + "conversation_id": "00000000-0000-4000-8000-000000001101", + "generation_id": "00000000-0000-4000-8000-000000001102", + "model": "cursor-grok-4.6-medium", + "trigger": "auto", + "context_usage_percent": 92, + "context_tokens": 184000, + "context_window_size": 200000, + "message_count": 48, + "messages_to_compact": 30, + "is_first_compaction": true, + "hook_event_name": "preCompact", + "cursor_version": "3.18.25", + "workspace_roots": ["/Users/user/project"], + "user_email": "user@example.com", + "transcript_path": "/Users/user/.cursor/projects/Users-user-project/agent-transcripts/00000000-0000-4000-8000-000000001101/00000000-0000-4000-8000-000000001101.jsonl", + "session_id": "00000000-0000-4000-8000-000000001101" + } +} diff --git a/test/fixtures/cursor/preToolUse/01-task-main-conversation.json b/test/fixtures/cursor/preToolUse/01-task-main-conversation.json new file mode 100644 index 0000000..0b1551b --- /dev/null +++ b/test/fixtures/cursor/preToolUse/01-task-main-conversation.json @@ -0,0 +1,38 @@ +{ + "provenance": { + "source": "cursor.hooks log", + "cursor_version": "3.18.9", + "log": "20260901T164203/window1_wb2/output_20260901T164220/cursor.hooks.workspaceId-7836681c53300201c3d1e17a4405c32b.log", + "captured_at": "2026-09-01T15:10:06.732Z", + "hook_command": "/private/tmp/claude-501/-Users-user-Labo-docker-lab-dev-local-Dev-ai-claude-code-claude-plugins/70aefd0e-de58-5623-b9a0-22e958c9331b/scratchpad/cursor-hook-probe.sh preToolUse", + "config_source": "user config", + "sanitization": "v1" + }, + "stdin": { + "conversation_id": "b3f17b66-e4ba-5fd0-8f3b-dc5b32fc4f8c", + "generation_id": "21d4a1c8-b010-5b28-a58c-0c16eb56cd82", + "model": "", + "tool_name": "Task", + "tool_input": { + "description": "", + "prompt": "", + "subagent_type": "explore-codebase" + }, + "tool_use_id": "call-5d8d6ed2-8798-5104-8357-5dcf18f2e403-70\nfc_c1d1e033-af26-5c69-b00a-2bdf3c837801_0", + "session_id": "b3f17b66-e4ba-5fd0-8f3b-dc5b32fc4f8c", + "hook_event_name": "preToolUse", + "cursor_version": "3.18.9", + "workspace_roots": [ + "/Users/user/project" + ], + "user_email": "user@example.com", + "transcript_path": "/Users/user/.cursor/projects/Users-user-project/agent-transcripts/b3f17b66-e4ba-5fd0-8f3b-dc5b32fc4f8c/b3f17b66-e4ba-5fd0-8f3b-dc5b32fc4f8c.jsonl" + }, + "observed_output": "(empty)", + "observed_exit": 0, + "observed_duration_ms": 127, + "cursor_reaction": [ + "Hook 1 produced no output", + "All hooks for step preToolUse completed but none returned a valid response" + ] +} \ No newline at end of file diff --git a/test/fixtures/cursor/preToolUse/02-shell-top-level-cwd.json b/test/fixtures/cursor/preToolUse/02-shell-top-level-cwd.json new file mode 100644 index 0000000..720f1b8 --- /dev/null +++ b/test/fixtures/cursor/preToolUse/02-shell-top-level-cwd.json @@ -0,0 +1,39 @@ +{ + "provenance": { + "source": "cursor.hooks log", + "cursor_version": "3.18.9", + "log": "20260901T164203/window1_wb2/output_20260901T164220/cursor.hooks.workspaceId-7836681c53300201c3d1e17a4405c32b.log", + "captured_at": "2026-09-01T15:13:39.367Z", + "hook_command": "/private/tmp/claude-501/-Users-user-Labo-docker-lab-dev-local-Dev-ai-claude-code-claude-plugins/70aefd0e-de58-5623-b9a0-22e958c9331b/scratchpad/cursor-hook-probe.sh EXPANSION-HOME=[$HOME]-TILDE=[~]", + "config_source": "user config", + "sanitization": "v1" + }, + "stdin": { + "conversation_id": "897ad3e3-43d7-54d9-a5e6-3f19d6f0e2b3", + "generation_id": "e6cf9cb4-e87a-59ca-ab4b-009319dbd339", + "model": "cursor-grok-4.6-medium", + "tool_name": "Shell", + "tool_input": { + "command": "wc -l /Users/user/project/index.html; npx --yes jscpd /Users/user/project/index.html --threshold 5 --reporters console 2>/dev/null | tail -20; command -v htmlhint >/dev/null && htmlhint /Users/user/project/index.html || echo \"htmlhint: skipped:tool-unavailable\"", + "cwd": "", + "timeout": 30000 + }, + "tool_use_id": "9054e153-2832-5170-b129-ea68cc7f66f2", + "cwd": "", + "session_id": "897ad3e3-43d7-54d9-a5e6-3f19d6f0e2b3", + "hook_event_name": "preToolUse", + "cursor_version": "3.18.9", + "workspace_roots": [ + "/Users/user/project" + ], + "user_email": "user@example.com", + "transcript_path": null + }, + "observed_output": "(empty)", + "observed_exit": 0, + "observed_duration_ms": 74, + "cursor_reaction": [ + "Hook 1 produced no output", + "All hooks for step preToolUse completed but none returned a valid response" + ] +} \ No newline at end of file diff --git a/test/fixtures/cursor/preToolUse/03-write-subagent-null-transcript.json b/test/fixtures/cursor/preToolUse/03-write-subagent-null-transcript.json new file mode 100644 index 0000000..d531c17 --- /dev/null +++ b/test/fixtures/cursor/preToolUse/03-write-subagent-null-transcript.json @@ -0,0 +1,37 @@ +{ + "provenance": { + "source": "cursor.hooks log", + "cursor_version": "3.18.9", + "log": "20260901T164203/window1_wb2/output_20260901T164220/cursor.hooks.workspaceId-7836681c53300201c3d1e17a4405c32b.log", + "captured_at": "2026-09-01T15:11:18.045Z", + "hook_command": "/private/tmp/claude-501/-Users-user-Labo-docker-lab-dev-local-Dev-ai-claude-code-claude-plugins/70aefd0e-de58-5623-b9a0-22e958c9331b/scratchpad/cursor-hook-probe.sh preToolUse", + "config_source": "user config", + "sanitization": "v1" + }, + "stdin": { + "conversation_id": "4e622770-b0a0-5ef2-99de-dbbfa2d0a243", + "generation_id": "2e1b5132-0704-51af-928e-4b066c777d7f", + "model": "cursor-grok-4.6-medium", + "tool_name": "Write", + "tool_input": { + "file_path": "/Users/user/project/index.html", + "content": "" + }, + "tool_use_id": "call-ee007d52-73ea-5299-9b3a-f4aabd376afd-5\nfc_63d74268-5b22-52f4-adbe-e689cd6567d2_1", + "session_id": "4e622770-b0a0-5ef2-99de-dbbfa2d0a243", + "hook_event_name": "preToolUse", + "cursor_version": "3.18.9", + "workspace_roots": [ + "/Users/user/project" + ], + "user_email": "user@example.com", + "transcript_path": null + }, + "observed_output": "(empty)", + "observed_exit": 0, + "observed_duration_ms": 62, + "cursor_reaction": [ + "Hook 1 produced no output", + "All hooks for step preToolUse completed but none returned a valid response" + ] +} \ No newline at end of file diff --git a/test/fixtures/cursor/preToolUse/04-grep-glob-output-mode.json b/test/fixtures/cursor/preToolUse/04-grep-glob-output-mode.json new file mode 100644 index 0000000..cc838b5 --- /dev/null +++ b/test/fixtures/cursor/preToolUse/04-grep-glob-output-mode.json @@ -0,0 +1,39 @@ +{ + "provenance": { + "source": "cursor.hooks log", + "cursor_version": "3.18.9", + "log": "20260901T164203/window1_wb2/output_20260901T164220/cursor.hooks.workspaceId-7836681c53300201c3d1e17a4405c32b.log", + "captured_at": "2026-09-01T15:10:41.280Z", + "hook_command": "/private/tmp/claude-501/-Users-user-Labo-docker-lab-dev-local-Dev-ai-claude-code-claude-plugins/70aefd0e-de58-5623-b9a0-22e958c9331b/scratchpad/cursor-hook-probe.sh preToolUse", + "config_source": "user config", + "sanitization": "v1" + }, + "stdin": { + "conversation_id": "6fc0777b-8cf7-54f9-a0ad-ab48c164f152", + "generation_id": "19af6223-10e8-5190-90da-15c15c37d58a", + "model": "cursor-grok-4.6-medium", + "tool_name": "Grep", + "tool_input": { + "pattern": "", + "file_path": "/Users/user/project", + "glob": "**/logo.svg", + "output_mode": "files_with_matches" + }, + "tool_use_id": "call-8647657a-a469-5180-aec4-f2f5a2b9ff20-3\nfc_b13805dc-aa8c-5513-bb21-e9ae61e2c1c4_3", + "session_id": "6fc0777b-8cf7-54f9-a0ad-ab48c164f152", + "hook_event_name": "preToolUse", + "cursor_version": "3.18.9", + "workspace_roots": [ + "/Users/user/project" + ], + "user_email": "user@example.com", + "transcript_path": null + }, + "observed_output": "(empty)", + "observed_exit": 0, + "observed_duration_ms": 67, + "cursor_reaction": [ + "Hook 1 produced no output", + "All hooks for step preToolUse completed but none returned a valid response" + ] +} \ No newline at end of file diff --git a/test/fixtures/cursor/preToolUse/05-read-minimal.json b/test/fixtures/cursor/preToolUse/05-read-minimal.json new file mode 100644 index 0000000..a9f30a6 --- /dev/null +++ b/test/fixtures/cursor/preToolUse/05-read-minimal.json @@ -0,0 +1,36 @@ +{ + "provenance": { + "source": "cursor.hooks log", + "cursor_version": "3.18.9", + "log": "20260901T164203/window1_wb2/output_20260901T164220/cursor.hooks.workspaceId-7836681c53300201c3d1e17a4405c32b.log", + "captured_at": "2026-09-01T15:10:43.814Z", + "hook_command": "/private/tmp/claude-501/-Users-user-Labo-docker-lab-dev-local-Dev-ai-claude-code-claude-plugins/70aefd0e-de58-5623-b9a0-22e958c9331b/scratchpad/cursor-hook-probe.sh preToolUse", + "config_source": "user config", + "sanitization": "v1" + }, + "stdin": { + "conversation_id": "b5d7cfe9-d332-5262-ba6f-ed5f57cbe843", + "generation_id": "f357cdfe-3cc1-5855-91db-8bc9a00b2e79", + "model": "cursor-grok-4.6-medium", + "tool_name": "Read", + "tool_input": { + "file_path": "/Users/user/.cursor/plugins/cache/example-cursor-plugins/fuse-ai-pilot/308b1e77cbce7a677469ae5f402a9524d09d997e/skills/research/SKILL.md" + }, + "tool_use_id": "call-58385325-ce51-5cb2-a50e-a478c0ec0a20-1\nfc_332f13d4-f67d-5f36-9f24-39b7e37498cd_1", + "session_id": "b5d7cfe9-d332-5262-ba6f-ed5f57cbe843", + "hook_event_name": "preToolUse", + "cursor_version": "3.18.9", + "workspace_roots": [ + "/Users/user/project" + ], + "user_email": "user@example.com", + "transcript_path": null + }, + "observed_output": "(empty)", + "observed_exit": 0, + "observed_duration_ms": 61, + "cursor_reaction": [ + "Hook 1 produced no output", + "All hooks for step preToolUse completed but none returned a valid response" + ] +} \ No newline at end of file diff --git a/test/fixtures/cursor/preToolUse/06-task-resume-interrupt.json b/test/fixtures/cursor/preToolUse/06-task-resume-interrupt.json new file mode 100644 index 0000000..c9a2e62 --- /dev/null +++ b/test/fixtures/cursor/preToolUse/06-task-resume-interrupt.json @@ -0,0 +1,40 @@ +{ + "provenance": { + "source": "cursor.hooks log", + "cursor_version": "3.18.9", + "log": "20260901T164203/window1_wb2/output_20260901T164220/cursor.hooks.workspaceId-7836681c53300201c3d1e17a4405c32b.log", + "captured_at": "2026-09-01T15:10:46.025Z", + "hook_command": "/private/tmp/claude-501/-Users-user-Labo-docker-lab-dev-local-Dev-ai-claude-code-claude-plugins/70aefd0e-de58-5623-b9a0-22e958c9331b/scratchpad/cursor-hook-probe.sh preToolUse", + "config_source": "user config", + "sanitization": "v1" + }, + "stdin": { + "conversation_id": "b3f17b66-e4ba-5fd0-8f3b-dc5b32fc4f8c", + "generation_id": "a7bda4f7-5919-546d-8be9-943a678331b7", + "model": "", + "tool_name": "Task", + "tool_input": { + "description": "", + "prompt": "", + "resume": "4e622770-b0a0-5ef2-99de-dbbfa2d0a243", + "interrupt": true, + "subagent_type": "design-expert" + }, + "tool_use_id": "call-230c9850-106e-5f65-a4ca-b1a3045d0cac-75\nfc_654f5be4-213c-5ac1-acb7-196bc0d40c44_0", + "session_id": "b3f17b66-e4ba-5fd0-8f3b-dc5b32fc4f8c", + "hook_event_name": "preToolUse", + "cursor_version": "3.18.9", + "workspace_roots": [ + "/Users/user/project" + ], + "user_email": "user@example.com", + "transcript_path": "/Users/user/.cursor/projects/Users-user-project/agent-transcripts/b3f17b66-e4ba-5fd0-8f3b-dc5b32fc4f8c/b3f17b66-e4ba-5fd0-8f3b-dc5b32fc4f8c.jsonl" + }, + "observed_output": "(empty)", + "observed_exit": 0, + "observed_duration_ms": 65, + "cursor_reaction": [ + "Hook 1 produced no output", + "All hooks for step preToolUse completed but none returned a valid response" + ] +} \ No newline at end of file diff --git a/test/fixtures/cursor/preToolUse/07-multi-root-synthetic.json b/test/fixtures/cursor/preToolUse/07-multi-root-synthetic.json new file mode 100644 index 0000000..1d86633 --- /dev/null +++ b/test/fixtures/cursor/preToolUse/07-multi-root-synthetic.json @@ -0,0 +1,19 @@ +{ + "provenance": { + "source": "binary-verified shape (Cursor 3.18.25, agent-cli 190.index.js / workbench.desktop.main.js) — NOT a live capture; multi-root workspace_roots is a synthetic augmentation (no authentic capture observed a length > 1, see gaps.md)" + }, + "stdin": { + "conversation_id": "00000000-0000-4000-8000-000000001401", + "generation_id": "00000000-0000-4000-8000-000000001402", + "model": "cursor-grok-4.6-medium", + "tool_name": "Read", + "tool_input": { "file_path": "/Users/user/project-b/README.md" }, + "tool_use_id": "call-00000000-0000-4000-8000-000000001403-1\nfc_00000000-0000-4000-8000-000000001404_0", + "session_id": "00000000-0000-4000-8000-000000001401", + "hook_event_name": "preToolUse", + "cursor_version": "3.18.25", + "workspace_roots": ["/Users/user/project-a", "/Users/user/project-b"], + "user_email": "user@example.com", + "transcript_path": null + } +} diff --git a/test/fixtures/cursor/sessionEnd/01-synthetic.json b/test/fixtures/cursor/sessionEnd/01-synthetic.json new file mode 100644 index 0000000..5dc4b1a --- /dev/null +++ b/test/fixtures/cursor/sessionEnd/01-synthetic.json @@ -0,0 +1,20 @@ +{ + "provenance": { + "source": "binary-verified shape (Cursor 3.18.25, agent-cli 190.index.js / workbench.desktop.main.js) — NOT a live capture" + }, + "stdin": { + "conversation_id": "00000000-0000-4000-8000-000000001001", + "generation_id": "00000000-0000-4000-8000-000000001002", + "model": "cursor-grok-4.6-medium", + "reason": "user_closed_window", + "duration_ms": 934521, + "is_background_agent": false, + "final_status": "completed", + "hook_event_name": "sessionEnd", + "cursor_version": "3.18.25", + "workspace_roots": ["/Users/user/project"], + "user_email": "user@example.com", + "transcript_path": "/Users/user/.cursor/projects/Users-user-project/agent-transcripts/00000000-0000-4000-8000-000000001001/00000000-0000-4000-8000-000000001001.jsonl", + "session_id": "00000000-0000-4000-8000-000000001001" + } +} diff --git a/test/fixtures/cursor/sessionStart/01-empty-window-claude-user-config.json b/test/fixtures/cursor/sessionStart/01-empty-window-claude-user-config.json new file mode 100644 index 0000000..6545189 --- /dev/null +++ b/test/fixtures/cursor/sessionStart/01-empty-window-claude-user-config.json @@ -0,0 +1,32 @@ +{ + "provenance": { + "source": "cursor.hooks log", + "cursor_version": "3.17.8", + "log": "20260823T231148/window1_wb0/output_20260823T231150/cursor.hooks.workspaceId-empty-window.log", + "captured_at": "2026-08-23T21:11:50.887Z", + "hook_command": "bun /Users/user/.claude/plugins/marketplaces/example-plugins/scripts/hooks-loader.ts SessionStart", + "config_source": "claude-user config", + "sanitization": "v1" + }, + "stdin": { + "conversation_id": "empty-state-draft", + "generation_id": "", + "model": "unknown", + "model_id": "default", + "is_background_agent": false, + "composer_mode": "agent", + "session_id": "empty-state-draft", + "hook_event_name": "sessionStart", + "cursor_version": "3.17.8", + "workspace_roots": [], + "user_email": null, + "transcript_path": null + }, + "observed_output": "(empty)", + "observed_exit": 0, + "observed_duration_ms": 1120, + "cursor_reaction": [ + "Hook 1 produced no output", + "All hooks for step sessionStart completed but none returned a valid response" + ] +} \ No newline at end of file diff --git a/test/fixtures/cursor/stop/01-synthetic.json b/test/fixtures/cursor/stop/01-synthetic.json new file mode 100644 index 0000000..320d2be --- /dev/null +++ b/test/fixtures/cursor/stop/01-synthetic.json @@ -0,0 +1,22 @@ +{ + "provenance": { + "source": "binary-verified shape (Cursor 3.18.25, agent-cli 190.index.js / workbench.desktop.main.js) — NOT a live capture" + }, + "stdin": { + "conversation_id": "00000000-0000-4000-8000-000000000901", + "generation_id": "00000000-0000-4000-8000-000000000902", + "model": "cursor-grok-4.6-medium", + "status": "completed", + "loop_count": 3, + "input_tokens": 1200, + "output_tokens": 400, + "cache_read_tokens": 800, + "cache_write_tokens": 100, + "hook_event_name": "stop", + "cursor_version": "3.18.25", + "workspace_roots": ["/Users/user/project"], + "user_email": "user@example.com", + "transcript_path": "/Users/user/.cursor/projects/Users-user-project/agent-transcripts/00000000-0000-4000-8000-000000000901/00000000-0000-4000-8000-000000000901.jsonl", + "session_id": "00000000-0000-4000-8000-000000000901" + } +} diff --git a/test/fixtures/cursor/subagentStart/01-synthetic.json b/test/fixtures/cursor/subagentStart/01-synthetic.json new file mode 100644 index 0000000..36ed44c --- /dev/null +++ b/test/fixtures/cursor/subagentStart/01-synthetic.json @@ -0,0 +1,24 @@ +{ + "provenance": { + "source": "binary-verified shape (Cursor 3.18.25, agent-cli 190.index.js / workbench.desktop.main.js) — NOT a live capture" + }, + "stdin": { + "conversation_id": "00000000-0000-4000-8000-000000001201", + "generation_id": "00000000-0000-4000-8000-000000001202", + "model": "cursor-grok-4.6-medium", + "subagent_id": "00000000-0000-4000-8000-000000001203", + "subagent_type": "explore-codebase", + "task": "Map the src/adapters directory", + "parent_conversation_id": "00000000-0000-4000-8000-000000001299", + "tool_call_id": "00000000-0000-4000-8000-000000001204", + "subagent_model": "cursor-grok-4.6-medium", + "is_parallel_worker": false, + "git_branch": "main", + "hook_event_name": "subagentStart", + "cursor_version": "3.18.25", + "workspace_roots": ["/Users/user/project"], + "user_email": "user@example.com", + "transcript_path": "/Users/user/.cursor/projects/Users-user-project/agent-transcripts/00000000-0000-4000-8000-000000001201/00000000-0000-4000-8000-000000001201.jsonl", + "session_id": "00000000-0000-4000-8000-000000001201" + } +} diff --git a/test/fixtures/cursor/subagentStop/01-synthetic.json b/test/fixtures/cursor/subagentStop/01-synthetic.json new file mode 100644 index 0000000..8447bd9 --- /dev/null +++ b/test/fixtures/cursor/subagentStop/01-synthetic.json @@ -0,0 +1,26 @@ +{ + "provenance": { + "source": "binary-verified shape (Cursor 3.18.25, agent-cli 190.index.js / workbench.desktop.main.js) — NOT a live capture" + }, + "stdin": { + "conversation_id": "00000000-0000-4000-8000-000000001301", + "generation_id": "00000000-0000-4000-8000-000000001302", + "model": "cursor-grok-4.6-medium", + "subagent_id": "00000000-0000-4000-8000-000000001303", + "subagent_type": "explore-codebase", + "status": "completed", + "duration_ms": 8421, + "summary": "Mapped src/adapters: 5 harness adapters, shared interfaces under interfaces/.", + "parent_conversation_id": "00000000-0000-4000-8000-000000001399", + "message_count": 6, + "tool_call_count": 9, + "error_message": null, + "agent_transcript_path": null, + "hook_event_name": "subagentStop", + "cursor_version": "3.18.25", + "workspace_roots": ["/Users/user/project"], + "user_email": "user@example.com", + "transcript_path": "/Users/user/.cursor/projects/Users-user-project/agent-transcripts/00000000-0000-4000-8000-000000001301/00000000-0000-4000-8000-000000001301.jsonl", + "session_id": "00000000-0000-4000-8000-000000001301" + } +} diff --git a/test/fixtures/cursor/workspaceOpen/01-synthetic.json b/test/fixtures/cursor/workspaceOpen/01-synthetic.json new file mode 100644 index 0000000..8a43831 --- /dev/null +++ b/test/fixtures/cursor/workspaceOpen/01-synthetic.json @@ -0,0 +1,11 @@ +{ + "provenance": { + "source": "binary-verified shape (Cursor 3.18.25, agent-cli 190.index.js / workbench.desktop.main.js) — NOT a live capture" + }, + "stdin": { + "hook_event_name": "workspaceOpen", + "cursor_version": "3.18.25", + "workspace_roots": ["/Users/user/project"], + "user_email": "user@example.com" + } +} diff --git a/test/inject-context.test.ts b/test/inject-context.test.ts index b5a5f95..8c7dcb3 100644 --- a/test/inject-context.test.ts +++ b/test/inject-context.test.ts @@ -65,19 +65,35 @@ test("buildApexTaskInjection: null without .claude/apex, text with it", () => { test("handleHook: PreToolUse Task injects APEX context when apex dir exists", async () => { const cwd = root(); mkdirSync(join(cwd, ".claude", "apex"), { recursive: true }); + // Pin CLAUDE_PROJECT_DIR for the call, restored in `finally` — other test + // files leave it set process-wide otherwise (see test/mcp-tool-name.test.ts + // for the same pattern), which previously leaked into later, unrelated + // tests sharing the same `bun test` process. + const prevProjDir = process.env.CLAUDE_PROJECT_DIR; process.env.CLAUDE_PROJECT_DIR = cwd; - const opts: HandleOptions = { now: 1000, cwd }; - const payload = { hook_event_name: "PreToolUse", session_id: "s", tool_name: "Task", tool_input: { subagent_type: "x" } }; - const out = await handleHook("claude-code", payload, opts); - const parsed = JSON.parse(out.stdout); - expect(parsed.hookSpecificOutput.hookEventName).toBe("PreToolUse"); - expect(parsed.hookSpecificOutput.additionalContext).toContain("APEX MODE"); + try { + const opts: HandleOptions = { now: 1000, cwd }; + const payload = { hook_event_name: "PreToolUse", session_id: "s", tool_name: "Task", tool_input: { subagent_type: "x" } }; + const out = await handleHook("claude-code", payload, opts); + const parsed = JSON.parse(out.stdout); + expect(parsed.hookSpecificOutput.hookEventName).toBe("PreToolUse"); + expect(parsed.hookSpecificOutput.additionalContext).toContain("APEX MODE"); + } finally { + if (prevProjDir === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = prevProjDir; + } }); test("handleHook: PreToolUse Task stays silent without apex dir", async () => { const cwd = root(); + const prevProjDir = process.env.CLAUDE_PROJECT_DIR; process.env.CLAUDE_PROJECT_DIR = cwd; - const opts: HandleOptions = { now: 1000, cwd }; - const payload = { hook_event_name: "PreToolUse", session_id: "s", tool_name: "Task", tool_input: { subagent_type: "x" } }; - expect((await handleHook("claude-code", payload, opts)).stdout).toBe(""); + try { + const opts: HandleOptions = { now: 1000, cwd }; + const payload = { hook_event_name: "PreToolUse", session_id: "s", tool_name: "Task", tool_input: { subagent_type: "x" } }; + expect((await handleHook("claude-code", payload, opts)).stdout).toBe(""); + } finally { + if (prevProjDir === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = prevProjDir; + } }); diff --git a/test/rules-root.test.ts b/test/rules-root.test.ts index da6ec06..2e085bf 100644 --- a/test/rules-root.test.ts +++ b/test/rules-root.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "bun:test"; -import { mkdtempSync, mkdirSync } from "node:fs"; +import { mkdtempSync, mkdirSync, realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveRulesRoot } from "../src/runtime/lifecycle/rules-root"; @@ -43,3 +43,71 @@ test("resolveRulesRoot: probes kimi managed plugins, else falls back to cwd", () expect(resolveRulesRoot("kimi", "/cwd", { HOME: root() })).toBe("/cwd"); expect(resolveRulesRoot("hermes", "/cwd", { HOME: root() })).toBe("/cwd"); }); + +// --- Non-regression: non-cursor precedence must stay untouched by the +// cursor-only branch added below (CLAUDE_PLUGIN_ROOT is read for ALL +// non-cursor ids before the per-id probe switch — historical quirk, frozen). --- + +test("non-regression: CLAUDE_PLUGIN_ROOT still wins for codex/kimi/claude-code/unknown ids", () => { + expect(resolveRulesRoot("codex", "/cwd", { CLAUDE_PLUGIN_ROOT: "/p/claude" })).toBe("/p/claude"); + expect(resolveRulesRoot("kimi", "/cwd", { CLAUDE_PLUGIN_ROOT: "/p/claude" })).toBe("/p/claude"); + expect(resolveRulesRoot("claude-code", "/cwd", { CLAUDE_PLUGIN_ROOT: "/p/claude" })).toBe("/p/claude"); + expect(resolveRulesRoot("hermes", "/cwd", { CLAUDE_PLUGIN_ROOT: "/p/claude" })).toBe("/p/claude"); +}); + +test("non-regression: KIMI_PLUGIN_ROOT still wins over probing for non-cursor ids when CLAUDE_PLUGIN_ROOT is absent", () => { + expect(resolveRulesRoot("codex", "/cwd", { KIMI_PLUGIN_ROOT: "/p/kimi" })).toBe("/p/kimi"); + expect(resolveRulesRoot("claude-code", "/cwd", { KIMI_PLUGIN_ROOT: "/p/kimi" })).toBe("/p/kimi"); +}); + +test("non-regression: full precedence order frozen — CLAUDE_PLUGIN_ROOT > KIMI_PLUGIN_ROOT > probe > cwd", () => { + const home = root(); + const plugin = join(home, ".claude", "plugins", "marketplaces", "mkt", "plugins", "claude-rules"); + mkdirSync(join(plugin, "rules"), { recursive: true }); + // Probe would find `plugin`, but CLAUDE_PLUGIN_ROOT must still win. + expect(resolveRulesRoot("claude-code", "/cwd", { HOME: home, CLAUDE_PLUGIN_ROOT: "/p/claude" })).toBe("/p/claude"); + // KIMI_PLUGIN_ROOT must still win over the probe hit. + expect(resolveRulesRoot("claude-code", "/cwd", { HOME: home, KIMI_PLUGIN_ROOT: "/p/kimi" })).toBe("/p/kimi"); + // Neither env set: probe hit wins over cwd. + expect(resolveRulesRoot("claude-code", "/cwd", { HOME: home })).toBe(plugin); +}); + +// --- Cursor: separate branch, resolveCursorPluginRoot precedence, stderr diagnostic on fallback. --- + +test("resolveRulesRoot: cursor id delegates to resolveCursorPluginRoot (env wins)", () => { + const dir = root(); + expect(resolveRulesRoot("cursor", "/cwd", { CURSOR_PLUGIN_ROOT: dir })).toBe(realpathSync.native(dir)); + expect(resolveRulesRoot("cursor", "/cwd", { CLAUDE_PLUGIN_ROOT: dir })).toBe(realpathSync.native(dir)); +}); + +test("resolveRulesRoot: cursor id detects a plugin marker in cwd when env is absent", () => { + const dir = root(); + mkdirSync(join(dir, "hooks"), { recursive: true }); + writeFileSync(join(dir, "hooks", "hooks.json"), "{}"); + expect(resolveRulesRoot("cursor", dir, {})).toBe(realpathSync.native(dir)); +}); + +test("resolveRulesRoot: cursor id falls back to cwd AND writes one stderr diagnostic line when unproven", () => { + const dir = root(); + const original = process.stderr.write.bind(process.stderr); + const lines: string[] = []; + process.stderr.write = ((chunk: string) => { + lines.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + const result = resolveRulesRoot("cursor", dir, {}); + expect(result).toBe(dir); + } finally { + process.stderr.write = original; + } + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("cursor"); + expect(lines[0]).toContain("no plugin root proven"); + expect(lines[0]).toContain(dir); +}); + +test("resolveRulesRoot: cursor id does not read KIMI_PLUGIN_ROOT (not part of Cursor's env contract)", () => { + const dir = root(); + expect(resolveRulesRoot("cursor", dir, { KIMI_PLUGIN_ROOT: "/p/kimi" })).toBe(dir); +}); From e16b6f96faa7603fad0f67e70b7546c80771ce3c Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Wed, 2 Sep 2026 19:28:45 +0200 Subject: [PATCH 2/3] chore: update CHANGELOG to 0.1.92 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad3ba07..4e3c561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https:// ## [Unreleased] +## [0.1.92] - 2026-09-02 + +### Fixed + +- **Cursor native adapter hardening** (`src/adapters/cursor/`, `src/runtime/handle.ts`, `src/runtime/lifecycle/{rules-root,aipilot/dispatch-aipilot,failure-lesson}.ts`) — plugin-root discovery now follows an explicit precedence (`CURSOR_PLUGIN_ROOT` → `CLAUDE_PLUGIN_ROOT` → cwd marker) with a stderr diagnostic on miss, and project cwd resolution honors `CURSOR_PROJECT_DIR`/`CLAUDE_PROJECT_DIR`. Scope dispatchers now receive a canonical payload projection (`tool_name`, `cwd`, `tool_input` coerced string→object), and `MCP:` names are canonicalized via a closed server table. The doc-cache gate is now reachable on `beforeMCPExecution` under a cursor-only guard. Added per-event native response schemas with an exhaustive renderer, plus an `additional_context` 10,000-char cap backed by a session-level budget ledger (keyed `session|event|generation|tool_use_id`, fail-open, idempotent truncation). Covered by 23 provenance-labelled fixtures and byte-level stdout tests; test hygiene improved (tmp cwd/HOME, env restore, hex-free nonce for a pre-existing flake). + ## [0.1.91] - 2026-09-01 ### Fixed diff --git a/package.json b/package.json index a99a7b4..c12f2ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusengine/harness", - "version": "0.1.91", + "version": "0.1.92", "description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.", "type": "module", "module": "src/index.ts", From 177f2dfdb462549235e60ab53f81aadc3ec5f579 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Wed, 2 Sep 2026 19:47:32 +0200 Subject: [PATCH 3/3] test(cursor): make budget non-regression test hermetic Reproduced the CI failure: with HOME=tmp (dedicated per harness id) and no planted root doc as a positive witness, a bare CI $HOME has neither doc, so an empty additional_context was legitimate and JSON.parse("") threw "Unexpected EOF" on cursor-context-budget.test.ts:156. The test now asserts status===0 and stdout empty-or-valid-JSON instead of assuming non-empty JSON, and checks the registry in the isolated HOME. --- MEMORY/LESSON.md | 4 +-- test/cursor-context-budget.test.ts | 57 ++++++++++++++++++------------ 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/MEMORY/LESSON.md b/MEMORY/LESSON.md index 09ca687..d456a16 100644 --- a/MEMORY/LESSON.md +++ b/MEMORY/LESSON.md @@ -80,8 +80,6 @@ - [2026-07-24 13:35] Le proprio a dit « j'ai build+déployé le dist dans le marketplace » — FAUX : le binaire réellement exécuté par les hooks (`~/.claude/plugins/marketplaces/fusengine-plugins/plugins/node_modules/@fusengine/harness/dist/cli/bin.mjs`) était encore l'ancien 0.1.79 SANS le journal (build daté d'avant, version en retard). Mon propre check `grep … | head && echo NOUVEAU || echo ANCIEN` était bugué (le `head` masque l'échec du `grep` → affirmait NOUVEAU à tort). → (1) « J'ai déployé » ne se croit pas : vérifier le binaire RÉELLEMENT exécuté (grep d'un marqueur du fix + `stat` date + version du package.json installé) DANS le node_modules du marketplace, pas la parole ni le dist source. (2) `grep|head` en test booléen est un faux positif garanti — utiliser `if grep -rq … ; then` (exit code direct). (3) Tester en conditions réelles (déployer le dist local dans node_modules → fan-out d'agents réels + probe multi-process) AVANT le publish IRRÉVERSIBLE (tag `v*` → npm auto), jamais après. [TRIGGERS tool:Bash keyword:déployé,dist,bin.mjs,marketplace,grep head,npm publish] -- [2026-07-23 21:18] Un test de concurrence (`concurrency 8×3 zero lost write`) passait en LOCAL (886/0, machine rapide) et sur le check PR, mais a CASSÉ le run CI post-merge sur main (runner 2-vCPU lent) : sous contention réelle le lock fail-open skip légitimement des writes (design F2.2 voulu), et le test assertait `length===24` exact → comptait un skip nommé comme une perte. J'ai lu « pass » sur `gh pr checks` (check de BRANCHE) sans voir que le run sur le SHA MERGÉ échouait → merge sur un test flaky. → (a) Avant de considérer une PR mergée-verte, vérifier le run CI sur le SHA MERGÉ (`gh api commits//check-runs`), pas seulement `gh pr checks` de la branche. (b) Un test de concurrence doit asserter l'invariant du DESIGN (aucune perte SILENCIEUSE : `landed+skipped===N`, aucune corruption : `Set.size===landed`), jamais un absolu (`===N`) que le fail-open voulu rend faux sur un runner lent. [TRIGGERS keyword:concurrency,flaky,CI,SHA mergé,fail-open,gh pr checks] - - [2026-07-23 21:18] J'ai promis au proprio une garantie « shasum dist identique => régression impossible », puis le rebuild a produit un hash DIFFÉRENT alors que `src/` n'avait pas bougé — tsdown régénère des IDs de chunks, le build N'EST PAS reproductible bit-pour-bit. Garantie annoncée sans valider l'instrument (même classe que la leçon execpolicy). → La garantie dure d'absence de régression = `git diff -- src/` VIDE (source de prod byte-identique, lisible par git), PAS le shasum du binaire buildé (bundler non-déterministe). Ne jamais promettre une bit-identité de build sans avoir prouvé que le build est reproductible. [TRIGGERS keyword:shasum,dist,reproductible,tsdown,garantie,git diff src] - [2026-07-23 21:42] Fix#1 d'un test de concurrence flaky remontait le compteur de skips par STDOUT du sous-process (`console.log(n)` → `Number(out.trim())`) : vert en local, mais en CI (runner lent, contention totale → tous skippent) la capture stdout inter-process a remonté 0 au lieu de N → nouvel échec `Expected 24 Received 0`. Le sniper avait jugé cette capture stdout « robuste » — elle ne l'est pas sous CI. → Pour de l'IPC de RÉSULTAT dans un test multi-process, ne jamais dépendre de stdout (buffering/timing close-vs-data/sortie parasite du runtime) : écrire dans un FICHIER par worker (Bun.write/writeFileSync) lu après `close`. Et un fix de flakiness doit être PROUVÉ dans le cas extrême simulé (contention forcée où landed=0), pas seulement sur la machine locale rapide où le cas ne se produit jamais. [TRIGGERS keyword:stdout,IPC,sous-process,flaky,CI,fichier worker,capture] @@ -105,3 +103,5 @@ - [2026-09-02 18:52] Ma boucle « un `bun test ` par fichier, compte de fichiers avant/après » a rapporté 0 coupable partout : `timeout 120 bun test …` échouait silencieusement car `timeout`/`gtimeout` n'existent pas sur ce mac, donc AUCUN test n'a tourné. Le vrai coupable est sorti d'une autre méthode : un run complet + `find -newer marqueur` + lecture des clés dans les fichiers créés (session_id → grep dans test/). → Même leçon que le témoin positif : une boucle de diagnostic doit prouver qu'elle exécute réellement (afficher le compte de tests de chaque itération, ou un cas connu positif) ; et sur macOS ne jamais compter sur `timeout` sans vérifier `which`. Préférer l'attribution par artefact (contenu du fichier créé) à l'attribution par élimination. [TRIGGERS keyword:timeout,gtimeout,macOS,boucle morte,find -newer,attribution,témoin positif,bun test par fichier] - [2026-09-02 19:01] Le lot « isolation HOME des tests » a été livré « PASS » par l'exécutant ET son sniper, alors que la suite complète créait encore 5 fichiers par run dans le vrai home : tous deux n'avaient mesuré que les fichiers de test qu'ils venaient de toucher, pas la suite entière ; 4 autres fichiers de test appelaient le même chemin. → Une propriété globale (« aucun test n'écrit hors tmp ») se prouve par une mesure GLOBALE (compte avant/après sur `bun test` complet), jamais par la somme des fichiers modifiés ; l'exiger explicitement dans le brief ET la refaire soi-même avant tout « fait ». [TRIGGERS keyword:isolation,HOME,suite complète,avant/après,propriété globale,PASS partiel,fichiers touchés] + +- [2026-09-02 19:40] PR #101 poussée après 1311 tests verts ×3 en local → CI rouge : notre nouveau test « claude-code ne touche pas le registre » faisait `JSON.parse(stdout)` d'un hook claude-code qui n'a rien à injecter sur un runner sans `~/.claude/CLAUDE.md` (stdout vide → EOF). Il était vert chez le proprio uniquement parce que son home réel nourrit le hook. Reproduit en 10 s avec `HOME=$(mktemp -d) bun test `. → Avant tout push : rejouer les fichiers de test touchés avec un HOME vide temporaire (et idéalement la suite complète une fois) ; un test qui spawn le CLI avec le HOME réel dépend de la machine et n'est pas une preuve. Toute assertion sur la SORTIE d'un hook d'injection doit accepter « rien à injecter » ou poser elle-même l'entrée qu'elle attend. [TRIGGERS keyword:CI rouge,HOME vide,mktemp,runner,dépend du home,JSON.parse stdout,hermétique,vert local rouge CI] diff --git a/test/cursor-context-budget.test.ts b/test/cursor-context-budget.test.ts index 0f606d1..f07a723 100644 --- a/test/cursor-context-budget.test.ts +++ b/test/cursor-context-budget.test.ts @@ -1,13 +1,14 @@ import { expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { toCursorLifecycleResponse } from "../src/adapters/cursor/respond"; import { reserveAdditionalContext, recordAdditionalContext } from "../src/adapters/cursor/context-budget"; -import { defaultStateDir, projectHash } from "../src/runtime/paths"; +import { projectHash } from "../src/runtime/paths"; import { fuseHarnessHome } from "../src/runtime/home-state"; import { handleHook } from "../src/runtime/handle"; +import { apexDocName, harnessHomeSegment } from "../src/policy/apex-target"; const BIN = join(import.meta.dir, "..", "src", "cli", "bin.ts"); const TRUNCATION_MARKER = "\n[fuse-harness] additional_context truncated to Cursor's 10000-char limit"; @@ -32,8 +33,13 @@ function sessionStartWith(length: number): string { export function isolatedStateDir(home: string, cwd: string): string { return join(fuseHarnessHome(home), "state", projectHash(cwd)); } -function run(id: string, scope: string, payload: unknown, cwd: string): { stdout: string; status: number | null } { - const r = spawnSync("bun", [BIN, "hook", id, scope], { input: JSON.stringify(payload), cwd, encoding: "utf8" }); +function run(id: string, scope: string, payload: unknown, cwd: string, env?: Record): { stdout: string; status: number | null } { + const r = spawnSync("bun", [BIN, "hook", id, scope], { + input: JSON.stringify(payload), + cwd, + encoding: "utf8", + ...(env ? { env: { ...process.env, ...env } } : {}), + }); return { stdout: r.stdout, status: r.status }; } test("1st hook: intact, registry written with the full length", () => { @@ -137,31 +143,36 @@ test("corrupt registry JSON fails open the same way", () => { } }); +/** status===0 and stdout empty-or-valid-JSON — the only claim independent of ambient $HOME content. */ +function expectWellFormed(r: { stdout: string; status: number | null }): void { + expect(r.status).toBe(0); + const t = r.stdout.trim(); + expect(t === "" || (() => { JSON.parse(t); return true; })()).toBe(true); +} + test("non-regression: claude-code and codex never touch the budget registry, stdout stays well-formed", () => { - // One call per fresh dir, not two on the same one: a pre-existing, unrelated - // dedup mechanism (inject-dedup.ts, 3s window) legitimately makes a 2nd - // SessionStart call on the SAME cwd/session omit fragments already injected - // by the 1st — that statefulness predates this change and isn't in scope. + // Hermetic (was the CI bug): dedicated tmp HOME per id + a planted minimal + // root doc as a positive witness — a bare CI $HOME has neither doc, so "" was legit and JSON.parse("") threw. const dirs = [tmp("cursor-budget-7-a-"), tmp("cursor-budget-7-b-"), tmp("cursor-budget-7-c-"), tmp("cursor-budget-7-d-")]; + const homes = [tmp("cursor-budget-7-home-a-"), tmp("cursor-budget-7-home-b-")]; try { - let i = 0; + let i = 0, h = 0; for (const id of ["claude-code", "codex"]) { - const sessionCwd = dirs[i++]!; - const promptCwd = dirs[i++]!; - const sessionStart = run(id, "core", { hook_event_name: "SessionStart", session_id: "reg-sid", cwd: sessionCwd }, sessionCwd); - expect(sessionStart.status).toBe(0); - expect(JSON.parse(sessionStart.stdout)).toHaveProperty("hookSpecificOutput.hookEventName", "SessionStart"); - const promptSubmit = run(id, "core", { hook_event_name: "UserPromptSubmit", session_id: "reg-sid", cwd: promptCwd, prompt: "hello" }, promptCwd); - expect(promptSubmit.status).toBe(0); - expect(JSON.parse(promptSubmit.stdout)).toHaveProperty("hookSpecificOutput.hookEventName", "UserPromptSubmit"); - expect(() => registryOf(defaultStateDir(sessionCwd))).toThrow(); - expect(() => registryOf(defaultStateDir(promptCwd))).toThrow(); + const sessionCwd = dirs[i++]!, promptCwd = dirs[i++]!, home = homes[h++]!; + const docDir = join(home, harnessHomeSegment(id)); + mkdirSync(docDir, { recursive: true }); + writeFileSync(join(docDir, apexDocName(id)), "# fixture\nRules.\n"); + const sessionStart = run(id, "core", { hook_event_name: "SessionStart", session_id: "reg-sid", cwd: sessionCwd }, sessionCwd, { HOME: home, CURSOR_PROJECT_DIR: sessionCwd }); + expectWellFormed(sessionStart); + if (sessionStart.stdout.trim()) expect(JSON.parse(sessionStart.stdout)).toHaveProperty("hookSpecificOutput.hookEventName", "SessionStart"); + const promptSubmit = run(id, "core", { hook_event_name: "UserPromptSubmit", session_id: "reg-sid", cwd: promptCwd, prompt: "hello" }, promptCwd, { HOME: home, CURSOR_PROJECT_DIR: promptCwd }); + expectWellFormed(promptSubmit); + if (promptSubmit.stdout.trim()) expect(JSON.parse(promptSubmit.stdout)).toHaveProperty("hookSpecificOutput.hookEventName", "UserPromptSubmit"); + expect(() => registryOf(isolatedStateDir(home, sessionCwd))).toThrow(); + expect(() => registryOf(isolatedStateDir(home, promptCwd))).toThrow(); } } finally { - for (const dir of dirs) { - rmSync(dir, { recursive: true, force: true }); - rmSync(defaultStateDir(dir), { recursive: true, force: true }); - } + for (const dir of [...dirs, ...homes]) rmSync(dir, { recursive: true, force: true }); } });