From 1be5f8cd186def18ca9e6754e063a5f6253079dd Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 17 Jul 2026 13:27:06 +0200 Subject: [PATCH] chore(ideai): #77 en QA, ouverture de #78 et #79 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enregistre l'état du registre de tickets : #77 entre en QA, #78 ouvre la dette UX de langue de l'écran Settings et #79 le test flaky relevé en cours de route. État runtime uniquement : aucun code de feature n'est touché. Co-Authored-By: Claude Opus 4.8 --- .../97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json | 394 +++++++++++++++++- .ideai/tickets/77/carnet.md | 153 +++++++ .ideai/tickets/77/issue.md | 77 ++++ .ideai/tickets/78/carnet.md | 6 + .ideai/tickets/78/issue.md | 34 ++ .ideai/tickets/79/carnet.md | 6 + .ideai/tickets/79/issue.md | 47 +++ .ideai/tickets/counter.json | 2 +- .ideai/tickets/index.json | 30 ++ 9 files changed, 746 insertions(+), 3 deletions(-) create mode 100644 .ideai/tickets/77/carnet.md create mode 100644 .ideai/tickets/77/issue.md create mode 100644 .ideai/tickets/78/carnet.md create mode 100644 .ideai/tickets/78/issue.md create mode 100644 .ideai/tickets/79/carnet.md create mode 100644 .ideai/tickets/79/issue.md diff --git a/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json b/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json index 7fad2e8..daf7226 100644 --- a/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json +++ b/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json @@ -8255,10 +8255,400 @@ "ticket_id": "690948d6-4063-4d28-ba3c-a6b6545f03c0", "conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4" }, - "state": "running", + "state": "completed", "wakePolicy": "recordOnly", "createdAtMs": 1784277549807, - "updatedAtMs": 1784277549807, + "updatedAtMs": 1784277679408, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784277679408, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "**#75 est intégré dans `develop` (`9463b7e`). Working tree propre, aucun push.**\n\n## Ce que j'ai commité\n\nDeux commits, sur une branche dédiée `feature/ticket75-pairing-code-keyboard` créée depuis `develop` :\n\n- `120d0d1 — fix(frontend)` : le code du fix (`PairingScreen.tsx`) et son test (`PairingScreen.test.tsx`). Je les ai gardés ensemble parce qu'ils forment une seule intention — le fix et son garde-fou, livrés par le même lot. Contrairement à #74, où le test venait d'un lot QA distinct et méritait son propre commit.\n- `481a7e2 — chore(ideai)` : tout l'état runtime du registre de tickets (clôture #74/#68, ouverture #75/#76).\n\n**Un arbitrage à te signaler sur le découpage.** Tu m'as demandé de séparer le code de l'état runtime, ce que j'ai fait, mais je n'ai pas poussé la séparation plus loin *à l'intérieur* de l'état runtime — alors que trois histoires s'y mélangent (la clôture de #74/#68 est la queue de comète du merge précédent, #76 est une dette backend sans rapport avec #75). La raison est concrète : dans `index.json`, l'entrée `#74` et les créations `#75`/`#76` sont **contiguës dans le même hunk**. Les séparer demandait d'éditer à la main un fichier généré par l'app, et j'aurais produit un commit intermédiaire où l'index contredit les `issue.md` sur disque. Un historique joli au prix d'un état incohérent est un mauvais échange. Le message du commit nomme les trois mouvements.\n\n## Ce que j'ai vérifié avant de merger\n\nJ'ai rejoué la suite moi-même plutôt que de merger sur la foi du rapport : `npx tsc --noEmit` sort en 0, `npx vitest run` donne **88 fichiers / 801 tests passés** en 16.89s. Les chiffres de DevFrontend sont exacts.\n\n## Ce que j'ai mergé\n\n`9463b7e` — merge `--no-ff` vers `develop`, sans conflit et sans rebase préalable (`develop` n'avait pas bougé depuis `aec4293`). Branche de feature supprimée.\n\n## État final\n\n`develop` est sur `9463b7e`, **en avance de 37 commits sur `origin/develop`**, arbre propre, aucune branche de feature en cours. Rien n'est poussé, conformément à mon périmètre local.\n\n## Ce qui reste en suspens\n\nLe fix **revient sur la décision de #69**, qui avait choisi `inputMode=\"numeric\"` en supposant un code purement numérique. C'est assumé et documenté dans le commit, mais si #69 est encore ouvert quelque part, son hypothèse est désormais fausse — ça vaut un coup d'œil de Main.\n\nLe **statut de #75 reste à `qa`** : la clôture formelle n'est pas ma décision. Et **#76** (casse du code d'appairage côté serveur) reste `open` — c'est la dette que la normalisation frontend masque pour l'instant ; tant qu'elle n'est pas traitée, tout autre client de l'API d'appairage reste exposé au problème.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "4104c7ef-6f81-4213-a7f6-47d189574fb1", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "c3e0d9d8-df36-4b00-b271-62d8aa78892c", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "c3e0d9d8-df36-4b00-b271-62d8aa78892c", + "ticket_id": "387732cc-e7e8-4e4f-afa2-9e5ec9f0b271", + "conversation_id": "652e01c1-67a5-0913-0272-fc456de31f6d" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784279233365, + "updatedAtMs": 1784279284217, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784279284217, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "**Spec UX Ticket #77**\n**Intention**\nCréer une surface unique “Appareils” accessible dans l’UI web et l’app desktop, mobile-first, pour gérer les appareils appairés sans redémarrer le serveur.\n\n**Navigation**\n- Entrée : `Paramètres` → `Appareils`.\n- Sur mobile web : entrée visible dans le menu principal ou menu de compte/app, libellé `Appareils`.\n- Même écran côté desktop, même vocabulaire, mêmes actions.\n\n**Liste Des Appareils**\nChaque ligne affiche uniquement :\n- Nom de l’appareil, niveau principal.\n- Badge `Cet appareil` si c’est la session courante.\n- Dernière activité : `Actif à l’instant`, `Aujourd’hui à 14:32`, `Hier`, puis date courte.\n- Date d’appairage en secondaire discret : `Appairé le 17 juil. 2026`.\n\nÀ éviter : User-Agent brut, IP, détails navigateur verbeux. Bruit technique inutile.\n\nDisposition mobile :\n```text\nAppareils\n[Appairer un appareil]\n\nCet appareil\niPhone d'Anthony [⋯]\nActif à l'instant\nAppairé le 17 juil. 2026\n\nMacBook bureau [⋯]\nDernière activité hier\nAppairé le 12 juil. 2026\n\n[Révoquer tous les appareils]\n```\n\nDesktop : même hiérarchie, éventuellement en liste/table compacte, mais pas une surface différente.\n\n**Appairer Un Nouvel Appareil**\nAction primaire en haut : `Appairer un appareil`.\n\nAu clic :\n- Génère un nouveau code.\n- Affiche un panneau/modal avec :\n - titre `Appairer un appareil`\n - code grand, lisible, groupé : `AB12-CD34`\n - bouton `Copier`\n - texte : `Saisissez ce code sur le nouvel appareil.`\n - durée : `Expire dans 10 min`\n\nTTL UX retenu : **10 minutes**. C’est court côté sécurité, mais assez confortable pour prendre le téléphone, ouvrir l’URL et saisir le code.\n\nExpiration :\n- À `00:00`, remplacer le code par l’état :\n - `Ce code a expiré.`\n - bouton primaire `Générer un nouveau code`\n- Ne pas afficher d’alerte anxiogène.\n- Si un nouveau code est généré, l’ancien disparaît immédiatement de l’UI.\n\nAprès appairage réussi :\n- Le panneau passe en succès bref : `Nouvel appareil appairé`.\n- La liste se rafraîchit.\n- Le nouvel appareil apparaît en haut ou à sa position normale avec un highlight temporaire 2-3 s.\n- Nom initial affiché selon la règle ci-dessous.\n\n**Identité D’un Appareil**\nDécision : nom saisi par l’utilisateur au moment de l’appairage, renommable après coup.\n\nParcours sur le nouvel appareil :\n1. saisie du code ;\n2. champ `Nom de cet appareil` ;\n3. valeur proposée dérivée simplement si possible : `iPhone`, `Android`, `Chrome sur Windows`, mais éditable ;\n4. bouton `Appairer`.\n\nRègles :\n- Le nom est obligatoire, mais prérempli.\n- Ne jamais exposer le User-Agent brut.\n- Action `Renommer` disponible dans le menu `⋯` de chaque appareil.\n- Longueur recommandée : 1 à 40 caractères.\n\n**Révocation**\nPar appareil :\n- Menu `⋯` → `Révoquer`.\n- Confirmation :\n - titre `Révoquer cet appareil ?`\n - texte `Il devra être appairé à nouveau pour accéder à IdeA.`\n - bouton destructif `Révoquer`\n - bouton secondaire `Annuler`\n\nSi l’appareil révoqué est l’appareil courant :\n- Confirmation spécifique :\n - titre `Révoquer cet appareil ?`\n - texte `Vous serez déconnecté immédiatement.`\n- Après confirmation : couper la session, fermer/rejeter les connexions actives, rediriger vers l’écran d’appairage.\n\nRévoquer tout :\n- Bouton destructif en bas de l’écran : `Révoquer tous les appareils`.\n- Ne demande pas de lire la liste.\n- Confirmation forte :\n - titre `Révoquer tous les appareils ?`\n - texte `Tous les appareils seront déconnectés immédiatement, y compris celui-ci. Pour revenir, il faudra appairer un appareil à nouveau.`\n - bouton destructif `Tout révoquer`\n - bouton secondaire `Annuler`\n- Après confirmation depuis le web : déconnexion immédiate et retour écran d’appairage.\n\n**Erreurs D’Appairage**\nFormulations non bavardes, sans aider un attaquant :\n\n- Code expiré :\n `Ce code n’est plus valide. Demandez un nouveau code depuis un appareil déjà appairé.`\n\n- Code déjà utilisé :\n `Ce code n’est plus valide. Demandez un nouveau code depuis un appareil déjà appairé.`\n\n- Code incorrect :\n `Code invalide ou expiré.`\n\n- Trop de tentatives :\n `Trop de tentatives. Réessayez dans quelques minutes avec un nouveau code.`\n\nMême message possible pour incorrect/expiré/utilisé côté API si Architect veut limiter l’oracle. L’UI peut spécialiser seulement quand le serveur expose explicitement un état sûr.\n\n**Implications DTO Pour Architect**\nPour afficher l’écran, il faut au minimum :\n- `deviceId`\n- `name`\n- `pairedAt`\n- `lastSeenAt`\n- `isCurrentDevice`\n\nPour le code :\n- `code`\n- `expiresAt`\n- éventuellement `ttlSeconds`\n\nPour les erreurs d’appairage, prévoir des codes stables mais non bavards côté client :\n- `invalid_or_expired`\n- `rate_limited`\n- éventuellement `already_used` seulement si accepté comme signal non sensible, sinon le mapper vers `invalid_or_expired`.\n\n**Critères D’Acceptation**\n- L’écran est utilisable confortablement sur téléphone.\n- L’UI web permet de générer un code sans desktop.\n- Aucun libellé ne parle d’“utilisateurs”.\n- Révoquer l’appareil courant déconnecte immédiatement.\n- Révoquer tout ne nécessite pas d’inspecter la liste.\n- Le nom d’appareil est compréhensible, éditable, et jamais un User-Agent brut.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "a9eadfef-5556-4d1a-a00d-65f8a036c9ef", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "dce19c75-9669-4e45-b8de-9950025157da", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "dce19c75-9669-4e45-b8de-9950025157da", + "ticket_id": "64b22654-40bd-4675-8d15-fc02630e8e0e", + "conversation_id": "7a2f446c-2efa-0c56-08dd-07cdc5cac19b" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784279322532, + "updatedAtMs": 1784279422699, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784279422699, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "J’ai lu #77 et vérifié les points d’entrée cités. Cadrage proposé.\n\n**Décision Transverse**\n\nOui, j’absorberais #76 dans ce lot : normalisation serveur du code avec suppression des espaces et tirets, puis uppercase, avant comparaison. C’est cohérent avec le refus du code réel groupé et évite que la sécurité dépende de la normalisation frontend.\n\nPour `already_used`, je tranche côté sécurité : ne pas l’exposer. Réponse API stable `invalid_or_expired` pour code absent, expiré, déjà consommé, remplacé ou incorrect. `rate_limited` uniquement pour le rate-limit. Les logs internes peuvent distinguer `already_used`, jamais le DTO public.\n\n**1. Persistance Appareils/Sessions**\n\nÀ modéliser comme un sous-domaine d’accès mono-utilisateur, pas comme de l’UI :\n\n- `domain`: entités/VO purs `PairedDevice`, `DeviceId`, `SessionTokenHash`, `DeviceName`, `PairingCode`.\n- `application`: use cases `PairDevice`, `ListDevices`, `RenameDevice`, `RevokeDevice`, `RevokeAllDevices`, `AuthenticateSession`, `TouchDevice`.\n- port domaine/application : `DeviceSessionStore`.\n- adapter infra : `FsDeviceSessionStore`.\n\nStore dans le data dir applicatif, pas dans `.ideai/` projet : par exemple `{app_data_dir}/security/devices.json`. Ce sont des accès à l’instance IdeA, pas des données du repo.\n\nFormat v1 :\n\n```json\n{\n \"version\": 1,\n \"devices\": [\n {\n \"deviceId\": \"uuid\",\n \"name\": \"Chrome sur Windows\",\n \"pairedAt\": \"2026-07-17T...\",\n \"lastSeenAt\": \"2026-07-17T...\",\n \"sessionTokenHash\": \"sha256:...\"\n }\n ]\n}\n```\n\nToken : remplacer `Uuid+Uuid` par 32 octets CSPRNG encodés base64url sans padding ou hex 64. Hash disque : `SHA-256(\"idea-session-v1\\0\" || token_bytes)`, stocké en hex avec préfixe algo.\n\nPourquoi pas Argon2id : ce n’est pas un mot de passe faible, c’est un secret aléatoire 256-bit. Un KDF lent n’ajoute presque rien contre la préimage et crée du coût serveur inutile. Le point dur est : token jamais en clair sur disque, comparaison constant-time du hash.\n\nDTO UI appareil :\n\n```ts\n{\n deviceId: string;\n name: string;\n pairedAt: string;\n lastSeenAt: string;\n isCurrentDevice: boolean;\n}\n```\n\nPas d’IP, pas de User-Agent.\n\n**2. Code Éphémère**\n\nLe code ne va pas dans le store persistant. Il reste en mémoire process, mais sort du `pairing_code: String` statique de `ServerState`.\n\nPort/use case :\n\n- `PairingCodeIssuer` ou service applicatif process-local injecté dans `ServerState`.\n- état mémoire : `{ code_hash, expires_at, generation_id, used }`.\n- TTL : 10 minutes.\n- génération à la demande invalide toujours le précédent.\n- usage unique : consommation atomique `consume(code, now) -> Valid | InvalidOrExpired`.\n\nNe pas persister le code ni son hash. Le flag `--new-code` appelle la même génération mémoire après démarrage du serveur et imprime le code. Sans flag, aucun code n’existe au boot.\n\nRoutes à ajouter :\n\n- `POST /api/pairing-code` authentifiée : génère un code, retourne `{ code, expiresAt, ttlSeconds: 600 }`.\n- desktop Tauri : commande équivalente qui appelle le même use case via le backend/composition root, pas via HTTP.\n- `POST /api/pair` devient `{ code, name }`.\n\n**3. Révocation Et WebSockets**\n\nNe pas faire fuiter WS dans le domaine. Le domaine/application publie un événement applicatif :\n\n- `DeviceRevoked { device_id }`\n- `AllDevicesRevoked`\n- éventuellement `SessionRevoked { device_id }`\n\nLe web-server, adapter entrant/transport, maintient un registre transport :\n\n```rust\nActiveConnectionRegistry {\n device_id -> Vec\n}\n```\n\nAu `validate_ws_upgrade`, `AuthenticateSession` doit retourner une `AuthenticatedDevice { device_id, token_hash }`, pas seulement booléen. `run_ws_connection` enregistre la connexion sous ce `device_id`.\n\nQuand `/api/devices/{id}/revoke`, `/api/logout`, ou `/api/devices/revoke-all` réussit :\n\n1. le use case supprime du `DeviceSessionStore`,\n2. publie l’événement,\n3. l’adapter HTTP/WS observe l’événement,\n4. il ferme les connexions WS concernées.\n\nConcrètement, la fermeture peut se faire via un canal `shutdown` par connexion, que la boucle `run_ws_connection` sélectionne en parallèle de `read_ws_frame`. À la fermeture, elle unregister `ws_pty_bridge` comme aujourd’hui. `OutputBridge` reste un outil de flux PTY, pas le mécanisme d’autorisation.\n\nPour les requêtes HTTP, `AuthenticateSession` revalide à chaque `/api/invoke`. Pour WS, l’autorisation est liée au `device_id` capturé à l’upgrade puis coupée par événement de révocation.\n\n**4. Rate-Limit `POST /api/pair`**\n\nLe rate-limit vit dans l’application comme port testable, avec adapter mémoire dans le web-server :\n\n- port : `PairAttemptLimiter`.\n- use case `PairDevice` l’appelle avant validation du code.\n- adapter prod initial : `InMemoryPairAttemptLimiter`.\n- adapter test : horloge fixe + fake limiter.\n\nGranularité recommandée :\n\n- par origine réseau normalisée : IP client réelle après validation reverse-proxy, sinon peer IP.\n- plus un bucket global faible pour éviter le brute force distribué simple.\n- exemple : 5 échecs / minute par origine, 30 échecs / minute global, reset court.\n- succès ou code régénéré peut nettoyer le bucket local, mais ce n’est pas nécessaire.\n\nLe port doit prendre `RateLimitKey { origin, route }`, pas des headers HTTP. La résolution proxy reste dans l’adapter HTTP.\n\n**5. Cookie**\n\nCookie long mais serveur source de vérité :\n\n- `HttpOnly`\n- `SameSite=Strict`\n- `Secure` selon config existante\n- `Path=/`\n- `Max-Age=34560000` environ 400 jours\n\nRenouvellement glissant : sur toute requête authentifiée réussie (`/api/invoke`, endpoints devices, possiblement refresh statique non nécessaire), renvoyer un `Set-Cookie` avec même token et Max-Age remis à 400 jours. Le serveur ne donne pas d’expiration métier à la session ; révocation seule invalide.\n\nLe `lastSeenAt` se met à jour avec throttling pour éviter d’écrire le JSON à chaque frame/requête : par exemple au plus une fois par 5 minutes par appareil.\n\n**6. `--new-code`**\n\nBranchement dans `ServerArgs` :\n\n- ajouter bool `new_code`.\n- usage : `idea --serve ... --new-code`.\n- après `ServerState` créé et listener bindé, appeler `generate_pairing_code(ttl=600s)`.\n- imprimer uniquement dans ce cas : `IdeA pairing code: ABCD1234`.\n\nGarantie “rien de persistant” :\n\n- `--new-code` ne modifie pas le store.\n- il ne crée pas d’appareil.\n- il ne change pas une config.\n- à chaque redémarrage avec le flag, un code mémoire TTL 10 min existe, invalide le précédent du même process par construction.\n- si laissé dans systemd, ça imprime un code à chaque démarrage mais uniquement éphémère et usage unique, comme accepté par #77.\n\nIl faut supprimer `EmbeddedServerHandle.pairing_code`/`EmbeddedServerStatusDto.pairing_code` comme source permanente. Le desktop doit demander explicitement un nouveau code via l’écran Appareils.\n\n**7. Lots B/F**\n\nLot B1 backend socle, bloquant :\n- `DeviceSessionStore` + hash tokens + migration depuis sessions mémoire sans compat nécessaire.\n- `PairDevice { code, name }`.\n- cookie Max-Age + renouvellement.\n- normalisation serveur code (#76).\n- tests unitaires store/hash/cookie.\n\nLot B2 code éphémère :\n- remplacer `pairing_code: String`.\n- endpoints génération code authentifiée.\n- `--new-code`.\n- suppression impression au boot hors flag.\n- TTL 10 min, usage unique, invalidation précédent.\n- erreurs `invalid_or_expired`.\n\nLot B3 révocation :\n- endpoints list/rename/revoke/revoke-all/logout-current.\n- event `DeviceRevoked`.\n- registre WS par `device_id`.\n- fermeture immédiate WS et cleanup `OutputBridge`.\n\nLot B4 rate-limit :\n- port `PairAttemptLimiter`.\n- adapter mémoire + tests horloge.\n- réponse `rate_limited`.\n\nLot F1 web session/contracts :\n- `POST /api/pair { code, name }`.\n- normalisation frontend espaces + tirets.\n- gestion erreurs `invalid_or_expired`, `rate_limited`.\n\nLot F2 écran Appareils partagé web/desktop :\n- liste devices.\n- générer code, affichage en deux blocs visuels de 4 sans tiret copiable.\n- renommer.\n- révoquer courant, révoquer autre, révoquer tout.\n- redirection appairage sur révocation courante ou 401/WS close auth.\n\nParallélisable :\n- F2 peut commencer sur DTO mock après validation des routes.\n- B1 et B2 doivent précéder l’intégration réelle de pairing.\n- B3 dépend de B1 pour `device_id`.\n- B4 peut avancer en parallèle de B2, mais doit s’insérer avant validation code dans `PairDevice`.\n\nÉcart avec UX : aucun sur la forme des DTO. Seule précision sécurité : `already_used` doit rester indiscernable de `invalid_or_expired`.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "0939ce1e-8490-4e9b-8382-c095cf2cd774", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "ticket_id": "6bc3f343-9e50-4ede-8b26-256233bb0336", + "conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e" + }, + "state": "failed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784283700869, + "updatedAtMs": 1784284300912, + "deadlineMs": null, + "result": { + "outcome": "failure", + "finished_at_ms": 1784284300912, + "exit_code": null, + "error": "Timeout: rendezvous inactivity window expired for target DevBackend", + "stdout_tail": null, + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "5d252215-fc7c-4605-a88f-b698124110fb", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "af7f86da-76bc-48e1-9900-71f45a624800", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "af7f86da-76bc-48e1-9900-71f45a624800", + "ticket_id": "e8297d3e-efa7-4bb2-9993-7da162885f0a", + "conversation_id": "09b15ec3-ce2f-0af2-2903-ef699df9de41" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784283845793, + "updatedAtMs": 1784284705729, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784284705729, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "F1 et F2 sont livrés et verts sur mock. Trois écarts de contrat demandent ton arbitrage — tous côté B3, aucun sur les DTO.\n\n## Ce que j'ai changé\n\n**F1 — session et contrats**\n\n`normalizePairingCode()` vit maintenant dans `domain/` (`frontend/src/domain/index.ts`) : elle enlève espaces **et tirets** puis met en majuscules. Je l'ai sortie de `PairingScreen` parce que c'est la règle canonique du code, pas une astuce de formulaire. `WebSession.pair(code, name)` poste `{code, name}` sur `/api/pair`.\n\nSur les erreurs, j'ai fait un choix que tu dois valider : **le message du serveur n'est jamais réaffiché**. Je mappe sur le code de la réponse (`invalid_or_expired` / `rate_limited`) vers les libellés figés au carnet. La raison est la décision de sécurité elle-même : si un futur backend écrit « code déjà utilisé » dans son `message`, un pont qui forwarde le message réintroduit l'oracle que le ticket interdit. Le mapping le rend structurellement impossible. Un test verrouille ce point.\n\nLe champ `Nom de cet appareil` est prérempli par `deriveDeviceName()` (`features/web/deviceName.ts`), pure et pilotée par une chaîne UA — donc testable sans navigateur. Elle rend `iPhone`, `Android`, `Chrome sur Windows`, et `Edge sur Windows` (l'UA d'Edge contient aussi `Chrome/` et `Safari/` ; les nommer tous « Chrome » rendrait deux navigateurs d'une même machine indiscernables dans la liste). Un test balaie tous les UA et vérifie qu'aucun fragment brut ne ressort.\n\n**F2 — écran Appareils**\n\nNouveau port `DeviceGateway` avec ses trois adapters : `TauriDeviceGateway` (desktop, via le composition root), `HttpDeviceGateway` (web, routes REST), `MockDeviceGateway`. L'écran `features/devices/DevicesScreen.tsx` est monté tel quel des deux côtés : section `Appareils` dans `SettingsView` (la liste des sections pilote déjà le menu, donc rien d'autre à câbler) et bouton `Appareils` dans le header de `WebApp`.\n\nJ'ai ajouté une primitive `request()` à `HttpInvoker` plutôt qu'un client HTTP séparé : le mapping 401 → retour appairage et la préservation de l'`ErrorDto` restent à un seul endroit, et `invoke()` délègue dessus.\n\nSur le code : les deux blocs de 4 sont deux `` séparés par du CSS, `aria-label` porte la valeur brute (un lecteur d'écran doit entendre ce qu'il faut taper), et `Copier` met `AB12CD34`. Un test assert que la valeur copiée matche `/^[0-9A-F]{8}$/` et ne contient ni tiret ni espace.\n\nSur la révocation de l'appareil courant, j'ai résisté à faire parler l'écran partagé à `WebSession` : le hook lit `isCurrentDevice` **avant** l'appel (après, la session est morte et la liste illisible), puis remonte `sessionEnded`. La surface qui monte l'écran décide de ce que ça veut dire — web : `forget()` + coupure WS + retour appairage ; desktop : rien, aucun appareil n'y est jamais `isCurrentDevice`. La révocation **subie** passe par le chemin `onUnauthorized` existant, qui était déjà branché.\n\n## Sortie de test réelle\n\n`npx vitest run` (suite complète) :\n\n```\n Test Files 91 passed (91)\n Tests 841 passed (841)\n Duration 13.99s\n```\n\nLes deux garde-fous, `npx vitest run src/app/no-direct-invoke.test.ts src/features/settings/desktop-only.test.ts` :\n\n```\n Test Files 2 passed (2)\n Tests 6 passed (6)\n```\n\n`npx tsc --noEmit` sort en 0. `npm run build:bundle` puis `npm run test:bundle-transport` :\n\n```\ndist: __IDEA_TRANSPORT__=\"tauri\" (1 marker)\ndist-web: __IDEA_TRANSPORT__=\"http\" (1 marker)\n```\n\nJ'ai ajouté 44 tests (devices 24, deviceName 7, pairing 11, webSession 5 réécrits). Un test existant a échoué et c'était utile : `mock.test.ts` assert la liste exhaustive des gateways, il m'a rappelé de déclarer `device` — je l'ai complété plutôt que contourné.\n\n## Ce que j'ai mocké en attendant B1/B2/B3\n\n`MockDeviceGateway` modélise ce que l'UI peut observer du contrat figé : liste avec un seul `isCurrentDevice`, codes hex 8 majuscules à TTL 600 s où générer remplace le précédent, rename, revoke, revoke-all. Il ne modélise ni le wire ni le cookie — c'est le job de `HttpDeviceGateway`, non couvert par test tant que B3 n'a pas figé ses routes. Tous les tests F2 tournent dessus.\n\n## Écarts de contrat — j'ai besoin de toi\n\n1. **Les chemins REST de B3 ne sont pas figés.** Le carnet nomme `POST /api/pairing-code` explicitement, mais B3 dit seulement « endpoints list/rename/revoke/revoke-all/logout ». J'ai déduit que la surface auth vit sur des routes dédiées (comme `/api/pair` et `/api/logout` aujourd'hui) et non sur le RPC générique `/api/invoke` — logique, puisque `/api/invoke` est lui-même auth-gated et ne peut pas porter la surface qui gère l'auth. J'ai posé `GET /api/devices`, `POST /api/devices/{id}/rename`, `POST /api/devices/{id}/revoke`, `POST /api/devices/revoke-all`. Tout est confiné à `adapters/http/deviceGateway.ts` : si DevBackend choisit autre chose, un seul fichier bouge.\n\n2. **Les noms de commandes Tauri ne sont pas figés non plus.** J'ai pris `list_devices`, `create_pairing_code`, `rename_device`, `revoke_device`, `revoke_all_devices`. Même confinement, dans `adapters/device.ts`.\n\n3. **Rien ne notifie l'UI qu'un appareil vient de s'appairer.** UX exige « Nouvel appareil appairé, liste rafraîchie, highlight 2-3 s », mais B3 ne prévoit aucun event domaine pour ça. Je poll `listDevices()` toutes les 3 s **uniquement pendant que le panneau de code est ouvert** — jamais en régime permanent. Ça marche, mais si tu préfères un event `DevicePaired` côté B3, c'est plus propre et je retire le polling.\n\nDeux points mineurs, pour info : la section desktop s'appelle `Appareils` au milieu de `AI Profiles` et `Deployment` (le vocabulaire est figé par UX, mais la surface Settings est incohérente en langue — c'est un arbitrage UX qui dépasse ce ticket) ; et j'ai écrit une `ConfirmDialog` locale à la feature plutôt qu'ajouter un dialog au design system, parce que c'est une décision UX/Architect, pas un effet de bord de ce ticket.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "f2f11942-2b6e-4fda-990e-1640535ad81a", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "target_agent_id": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", + "ticket_id": "3fdc7a01-9f04-49f9-bc3c-f0e9da1c7e89", + "conversation_id": "dd35eeb0-2329-0dfa-3255-df7dba90a0aa" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784284373462, + "updatedAtMs": 1784284464560, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784284464560, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Validation QA B1 terminée. Verdict : **vert sur le périmètre B1**, aucune régression/correction à demander.\n\nCommandes relancées :\n\n- `cargo test --offline -p domain device -- --nocapture` : OK, `2 passed`\n- `cargo test --offline -p application device -- --nocapture` : OK, `4 passed`\n- `cargo test --offline -p infrastructure device_session -- --nocapture` : OK, `3 passed`\n- `cargo test --offline -p web-server pairing -- --nocapture` : OK, `5 passed`\n- `cargo test --offline -p web-server authenticated_invoke_renews_session_cookie_max_age -- --nocapture` : OK, `1 passed`\n- `cargo test --offline -p web-server websocket_upgrade -- --nocapture` : OK, `4 passed`\n- `cargo test --offline -p web-server logout_revokes_session_for_http_and_websocket -- --nocapture` : OK, `1 passed`\n- `cargo test --offline -p backend --lib -- --nocapture` : OK, `41 passed`, `7 ignored`\n\nSuites larges :\n\n- `cargo test --offline -p web-server -- --nocapture` : compile OK, `66 passed`, `2 failed`\n - échecs uniquement sur `run_embedded_stop_shuts_down_accept_loop` et `run_embedded_with_core_uses_injected_core_for_http_invokes`\n - cause réelle : `failed to bind 127.0.0.1:0: Operation not permitted (os error 1)`\n - hors périmètre B1, restriction sandbox locale\n\n- `cargo test --offline -p app-tauri embedded_server -- --nocapture` : compile OK, `9 passed`, `1 failed`\n - échec unique sur `start_is_idempotent_and_stop_stops_running_server`\n - même cause : bind `127.0.0.1:0` interdit par sandbox\n - hors périmètre B1\n\nLecture ciblée faite sur `DeviceSessionStore/FsDeviceSessionStore`, VO domaine, use cases et wiring `web-server` : les points demandés sont couverts par tests verts ou code lu, notamment token 32 octets généré puis hashé `sha256:`, comparaison constant-time côté hash, normalisation serveur espaces+tirets+casse, cookie `Max-Age=34560000` avec renouvellement, nom obligatoire borné, et `lastSeenAt` throttlé à 5 minutes.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "5a540a56-e62e-449e-8803-76cbe6695896", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "ticket_id": "7b652059-c5db-4e97-ba23-3f358cce850b", + "conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e" + }, + "state": "failed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784284490503, + "updatedAtMs": 1784285090642, + "deadlineMs": null, + "result": { + "outcome": "failure", + "finished_at_ms": 1784285090642, + "exit_code": null, + "error": "Timeout: rendezvous inactivity window expired for target DevBackend", + "stdout_tail": null, + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "2156395d-f4ac-4de1-8f85-2247f45c31da", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "ticket_id": "75c2b626-4cf9-4038-b4cb-4b751e3d919c", + "conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e" + }, + "state": "failed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784285235779, + "updatedAtMs": 1784285836228, + "deadlineMs": null, + "result": { + "outcome": "failure", + "finished_at_ms": 1784285836228, + "exit_code": null, + "error": "Timeout: rendezvous inactivity window expired for target DevBackend", + "stdout_tail": null, + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "3f963c10-c33b-42bf-8c3f-ff234734832c", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "af7f86da-76bc-48e1-9900-71f45a624800", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "af7f86da-76bc-48e1-9900-71f45a624800", + "ticket_id": "ac7212ef-ef45-4f70-8658-8ba4e584920a", + "conversation_id": "09b15ec3-ce2f-0af2-2903-ef699df9de41" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784285928768, + "updatedAtMs": 1784286116073, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784286116073, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "`invalid_name` est mappé et l'erreur s'affiche sur le champ du nom — la structure le permettait sans refonte.\n\n## Ce que j'ai changé\n\n**Le mapping** (`adapters/http/webSession.ts`). J'ai ajouté `invalidName` à `PairingErrorCode` et le libellé `Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.` La clause finale n'est pas du remplissage : sans elle, le réflexe est de fermer et de regénérer un code, ce qui est exactement le geste inutile que la correction de B3 rend évitable. Les parenthèses disent quoi corriger sans réafficher le message du serveur — le mapping local est conservé tel quel, comme demandé.\n\nJ'ai câblé `invalid_name` **uniquement sur le code wire**, pas sur le statut 400. Un 400 nu (body illisible, requête malformée) tombe toujours dans `pairingFailed`. Sinon une erreur sans rapport enverrait l'utilisateur éditer un champ qui allait très bien. `invalid_or_expired` et `rate_limited` sont intacts.\n\n**La présentation** (`features/web/PairingScreen.tsx`). Le composant `Field` avait déjà tout : une prop `error` qui remplace le hint, le `role=\"alert\"` et le câblage `aria-describedby`. J'ai donc changé l'état d'erreur de `string` en `{code, message}` et je route : `invalidName` va sur le champ du nom, tout le reste reste en erreur globale. Une trentaine de lignes, aucune restructuration. Le champ du code n'est plus peint en rouge quand c'est le nom qui est en cause.\n\n**Un désalignement client/serveur que j'ai trouvé en vérifiant** (`features/web/deviceName.ts`). Tu m'as demandé de garder les règles alignées sur `DeviceName::new`, donc je suis allé le lire : il fait `trim()` puis `chars().count()` avec la borne 1..=40, c'est-à-dire des **code points**. Mon `clampDeviceName` utilisait `slice(0, 40)`, qui compte des unités UTF-16. Sur un nom contenant des caractères hors du plan multilingue de base (emoji), `slice` pouvait **couper une paire de substitution en deux** et mettre un demi-caractère sur le fil. Corrigé avec `Array.from(...)`, qui itère par code point comme Rust. C'était en dehors de ta demande stricte, mais c'est précisément l'alignement que tu voulais, et le bug était réel.\n\nUne nuance à connaître : le `maxLength={40}` du champ HTML compte lui aussi en UTF-16, donc un nom en emoji est coupé plus tôt que le serveur ne l'exigerait. C'est plus strict que le serveur, donc ça ne peut jamais déclencher `invalid_name` — je l'ai laissé.\n\n## Commande et sortie réelle\n\n`cd frontend && npx vitest run` :\n\n```\n Test Files 91 passed (91)\n Tests 846 passed (846)\n Duration 16.04s\n```\n\n`npx tsc --noEmit` sort en 0.\n\nCinq tests ajoutés. Sur le mapping : `invalid_name` → libellé dédié, un 400 nu ne blâme pas le nom, et le message du serveur n'est pas réaffiché pour `invalid_name` non plus. Sur l'écran : l'erreur de nom est bien attachée au champ (`aria-describedby` pointe le message, `aria-invalid` sur le nom, pas sur le code, et aucun `pairing-error` global), et un code refusé reste une erreur globale sans marquer le nom.\n\nUn détail de méthode : j'ai vérifié que `Input` implémente bien `aria-invalid` avant de m'appuyer dessus dans les assertions — sinon ces deux tests passaient à vide sans rien garantir.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "3078c12c-844a-4850-bffc-75d89f09048d", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", + "ticket_id": "c83fc2e6-9808-4010-8727-3c7e6330ba2e", + "conversation_id": "08336578-5b47-09d2-2f41-5cc483f101f4" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784286145550, + "updatedAtMs": 1784286563757, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784286563757, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "**Verdict QA**\nJe ne donnerais pas un feu vert “commit + rebuild” tel quel. Les 4 durcissements fonctionnels passent en ciblé et les tests ne sont pas tautologiques sur ces points, mais il reste des signaux rouges autour de la commande standard demandée, de la parallélisation `web-server`, de warnings `clippy` nouveaux, et d’une surface frontend stale sur `pairingCode`.\n\n**Bloquant / À Corriger**\n1. `cargo test -p web-server` échoue en parallèle avec 6 failures. Deux sont les binds sandbox connus, mais 4 autres sont des tests WebSocket qui passent isolés et en mono-thread. C’est une pollution d’état/course de tests, pas un vert fiable.\n\nSortie réelle :\n```text\ntest result: FAILED. 73 passed; 6 failed\n\nfailures:\n tests::run_embedded_stop_shuts_down_accept_loop\n tests::run_embedded_with_core_uses_injected_core_for_http_invokes\n tests::websocket_launch_agent_emits_attached_with_assigned_conversation_id\n tests::websocket_launch_agent_reattach_replays_scrollback_without_respawn\n tests::websocket_open_terminal_emits_attached_with_empty_scrollback\n tests::websocket_upgrade_accepts_valid_cookie_and_origin\n\nassertion `left == right` failed\n left: \"error\"\n right: \"terminal.attached\"\n\ncalled `Option::unwrap()` on a `None` value\n```\n\nEn mono-thread :\n```text\ncargo test -p web-server -- --test-threads=1\ntest result: FAILED. 77 passed; 2 failed\nfailed to bind 127.0.0.1:0: Operation not permitted\n```\n\n2. La commande demandée est rouge :\n```text\ncargo test -p domain -p application -p infrastructure -p web-server\ntest result: FAILED. 275 passed; 10 failed\n```\nLes 10 failures visibles sont hors #77, tous sur `infrastructure::session::openai_compat` avec :\n```text\nbind: Os { code: 1, kind: PermissionDenied, message: \"Operation not permitted\" }\n```\nDonc pas une régression #77, mais la commande attendue n’est pas verte dans ce sandbox.\n\n3. Frontend stale autour du code d’appairage au démarrage. Le Rust a bien supprimé `pairing_code` du status, mais le TS/mock/ancienne UI gardent encore l’ancien modèle :\n- [domain/index.ts](/home/anthony/Documents/Projects/IdeA/frontend/src/domain/index.ts:193) documente encore `EmbeddedServerStatus.pairingCode`.\n- [DeploymentSettings.tsx](/home/anthony/Documents/Projects/IdeA/frontend/src/features/settings/DeploymentSettings.tsx:337) affiche encore un panneau “Pairing” basé sur `status.pairingCode`.\n- [mock/index.ts](/home/anthony/Documents/Projects/IdeA/frontend/src/adapters/mock/index.ts:1477) injecte encore `pairingCode: \"MOCK-PAIR-4242\"`.\n- Le test historique `DeploymentSettings.test.tsx` valide encore “start server => copy pairing code”.\n\nÇa contredit le contrat #77 “sans `--new-code`, aucun code n’existe au démarrage”. La prod Rust ne semble pas exposer le secret, mais la surface frontend/mock garde une assertion alignée sur l’ancien comportement.\n\n**Durcissements**\n- TTL 10 min / usage unique / génération invalide l’ancien : OK.\n```text\ncargo test -p web-server pairing_code -- --nocapture\nrunning 4 tests\ntest tests::pairing_code_expires_and_returns_public_invalid_or_expired ... ok\ntest tests::generating_new_pairing_code_invalidates_previous_code ... ok\ntest tests::pairing_code_is_single_use ... ok\ntest tests::pairing_code_endpoint_requires_auth_and_returns_ttl ... ok\n```\n\n- `invalid_or_expired` sans oracle : OK en corps/statut pour absent/expiré/utilisé/faux.\n```text\ncargo test -p web-server invalid_or_expired -- --nocapture\nrunning 2 tests\ntest tests::invalid_or_expired_response_does_not_leak_cause ... ok\n```\n\n- Rate-limit horloge injectée : OK.\n```text\ncargo test -p infrastructure pair_attempt_limiter -- --nocapture\nrunning 2 tests\ntest pair_attempt_limiter::tests::limits_five_failures_per_origin_per_minute_with_injected_clock ... ok\ntest pair_attempt_limiter::tests::limits_thirty_failures_globally_per_minute ... ok\n```\n\n- Tokens hashés sur disque : OK, vérifié via le vrai `FsDeviceSessionStore`.\n```text\n\"sessionTokenHash\": \"sha256:f9714c2b7b3b0a332476f55ba0f022424a68acfea50781cfcf128c2cb94d3433\"\ncontains_raw_hex=false\ncontains_sha256_prefix=true\n```\n\n- Révocation coupe WS A sans couper B : OK, le test fait réellement A/B.\n```text\ncargo test -p web-server closes -- --nocapture\nrunning 2 tests\ntest tests::revoke_all_closes_all_active_connections ... ok\ntest tests::device_revocation_closes_only_matching_active_connections ... ok\n```\n\n**Autres Commandes**\nFrontend :\n```text\nnpx vitest run\nTest Files 91 passed (91)\nTests 846 passed (846)\n\nnpx tsc --noEmit\n# code 0, pas d'erreur\n```\n\nGarde-fous :\n```text\nnpx vitest run src/app/no-direct-invoke.test.ts\nTest Files 1 passed; Tests 3 passed\n\nnpx vitest run src/features/settings/desktop-only.test.ts\nTest Files 1 passed; Tests 3 passed\n\nnpm run test:bundle-transport\ndist: __IDEA_TRANSPORT__=\"tauri\" (1 marker)\ndist-web: __IDEA_TRANSPORT__=\"http\" (1 marker)\n```\n\n`clippy` :\n```text\ncargo clippy -p domain -p application -p infrastructure -p backend -p web-server -p app-tauri --all-targets\nFinished `dev` profile\n```\nMais warnings probablement nouveaux dans `web-server` touché :\n```text\ncrates/web-server/src/lib.rs:587: manual_unwrap_or_default\ncrates/web-server/src/lib.rs:1096: question_mark\n```\n\nMon verdict : **durcissements #77 validés en ciblé, mais pas de feu vert global tant que le rouge `web-server` parallèle et le stale `pairingCode` frontend/mock ne sont pas traités ou explicitement arbitrés.**", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "1d78f19e-3707-4f40-a0ec-0610f8a9988a", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "af7f86da-76bc-48e1-9900-71f45a624800", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "af7f86da-76bc-48e1-9900-71f45a624800", + "ticket_id": "7816fbc3-973b-442f-9e13-94424ea9945e", + "conversation_id": "09b15ec3-ce2f-0af2-2903-ef699df9de41" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784286639903, + "updatedAtMs": 1784286919811, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784286919811, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Le nettoyage demandé est fait et vert. Mais en cherchant les autres restes, j'ai trouvé **deux écarts qui rendraient l'écran Appareils non fonctionnel en production** — dont un que je dois te laisser trancher.\n\n## Ce que j'ai changé (les 4 points)\n\n`domain/index.ts` : `pairingCode` retiré d'`EmbeddedServerStatus`, et le commentaire réécrit — il décrit maintenant pourquoi il n'y en a pas (un code n'est pas une propriété d'un serveur qui tourne, il n'existe que quand on en demande un).\n\n`adapters/mock/index.ts` : `pairingCode: \"MOCK-PAIR-4242\"` supprimé.\n\n`features/settings/DeploymentSettings.tsx` : panneau `Pairing` remplacé par le renvoi vers `Settings → Appareils`. J'ai gardé le titre du panneau pour que le repère visuel reste au même endroit.\n\n`DeploymentSettings.test.tsx` : j'ai remplacé le test par deux tests vrais plutôt que retirer l'assertion. Le premier vérifie qu'**aucun code n'apparaît, serveur démarré ou non** — c'est le vrai garde-fou de régression, il pin le fait qu'un code ne peut plus revenir sur cette surface. Le second vérifie que le renvoi est là, pour que le cul-de-sac que tu voulais éviter reste évité. Au passage, mon premier essai s'est planté sur un mauvais libellé d'ancrage : le test a échoué, je l'ai corrigé sur la sortie réelle.\n\n## Ce que j'ai trouvé en cherchant — et c'est plus grave\n\nTon diagnostic sur le mécanisme (« le mock fournit une valeur que le vrai backend n'enverra jamais ») s'applique **aussi à mon propre lot F2**, à deux endroits. Mes tests sont verts pour exactement la raison que tu décris : mon mock et mes fixtures nourrissent des formes que le backend n'envoie pas.\n\n**1. `list_devices` renvoie un objet, pas un tableau.** B3 expose `DeviceListDto { devices: [...] }`. Mon `TauriDeviceGateway.listDevices()` faisait `invoke(\"list_devices\")` et rendait l'objet tel quel — la liste desktop aurait planté au premier rendu. **Je l'ai corrigé** : ce n'est pas un arbitrage, juste mon adapter qui ne collait pas à un DTO livré. Mon gateway HTTP tolérait déjà les deux formes, c'était une incohérence entre mes deux adapters.\n\n**2. Les trois timestamps sont des millisecondes epoch en chaîne, pas de l'ISO-8601. Ça, je te le laisse.** `pairedAt`, `lastSeenAt` (`clock.now_millis().to_string()`, fixtures de test `\"1000\"`) et `expiresAt` (`expires_at_ms.to_string()`). Mon `formatActivity.ts` fait `new Date(iso)` partout. Vérifié en réel :\n\n```\nbackend sends: 1784286814274\nnew Date(s) → Invalid Date\n```\n\nConcrètement, en production aujourd'hui : chaque ligne d'appareil afficherait `—` et `Appairé le —`, et **chaque code fraîchement généré s'afficherait immédiatement comme `Ce code a expiré.`** — le panneau d'appairage serait inutilisable. Le carnet a figé les noms des champs, pas leur encodage ; c'est le trou.\n\nDeux directions, et je ne tranche pas seul parce que ça traverse la frontière backend :\n\n- **Frontend s'aligne** (ma recommandation) : un `parseInstant()` dans `formatActivity.ts` qui lit l'epoch-ms, plus les fixtures et le mock à corriger. Le backend est livré et testé, l'epoch-ms est sans ambiguïté de fuseau. ~15 min, confiné à un fichier plus les tests.\n- **Backend passe en ISO-8601** : plus lisible sur le fil, mais ça touche B1/B3 déjà validés.\n\nÀ noter pour ton arbitrage : le reste du codebase utilise plutôt des timestamps **numériques** suffixés `Ms` (`createdAtMs`, `updatedAtMs`), donc `pairedAt: \"1784286814274\"` en *chaîne* est une troisième convention, ni ISO ni `...Ms`. Si tu veux uniformiser, c'est le moment.\n\nDis-moi la direction et je l'applique.\n\n## Commande et sortie réelle\n\n`cd frontend && npx vitest run` :\n\n```\n Test Files 91 passed (91)\n Tests 847 passed (847)\n Duration 15.82s\n```\n\n`npx tsc --noEmit` sort en 0.\n\nUne précision d'honnêteté sur ce vert : il ne prouve rien sur le point 2 ci-dessus. Les tests F2 passent parce qu'ils nourrissent de l'ISO — c'est le même vert trompeur, et il le restera tant que la décision d'encodage n'est pas prise.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "f27eb580-310a-444c-9547-b2dd3a9f69fc", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "ticket_id": "2d3233b2-e1c6-4a0e-9dd1-361081455636", + "conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784286770999, + "updatedAtMs": 1784286852321, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784286852321, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Corrigé uniquement dans [crates/web-server/src/lib.rs](/home/anthony/Documents/Projects/IdeA/crates/web-server/src/lib.rs:585).\n\n**Diff**\n```diff\n@@\n- match self\n+ self\n .app\n .authenticate_session\n .execute(AuthenticateSessionInput { token_bytes })\n .await\n- {\n- Ok(device) => device,\n- Err(_) => None,\n- }\n+ .unwrap_or_default()\n@@\n- if let Err(response) = validate_reverse_proxy(headers, peer_ip, state, WS_PATH) {\n- return Err(response);\n- }\n+ validate_reverse_proxy(headers, peer_ip, state, WS_PATH)?;\n```\n\n**`cargo clippy -p web-server --all-targets`**\n```text\nFinished `dev` profile [unoptimized + debuginfo] target(s) in 13.54s\nexit code: 0\n```\nLa commande sort encore des warnings préexistants dans `domain`, `application`, `infrastructure` et `backend`, mais plus les deux warnings `web-server` signalés.\n\n**`cargo test -p web-server`**\n```text\nrunning 79 tests\n...\ntest result: FAILED. 73 passed; 6 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s\n\nfailures:\ntests::run_embedded_stop_shuts_down_accept_loop\ntests::run_embedded_with_core_uses_injected_core_for_http_invokes\ntests::websocket_close_terminal_emits_exited_status_and_releases_session\ntests::websocket_launch_agent_reattach_replays_scrollback_without_respawn\ntests::websocket_launch_agent_same_cell_is_idempotent_singleton\ntests::websocket_upgrade_requires_valid_cookie\n```\n\nCause visible dans la sortie réelle ici :\n```text\nfailed to bind 127.0.0.1:0: Operation not permitted (os error 1)\nFailed building the Runtime: Os { code: 24, kind: Uncategorized, message: \"Too many open files\" }\n```\nDonc même échec sandbox réseau/FD que celui annoncé, sans correction tentée dessus.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "87d2feff-19a8-4f24-9f4b-a789716cb28d", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "dce19c75-9669-4e45-b8de-9950025157da", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "dce19c75-9669-4e45-b8de-9950025157da", + "ticket_id": "a1219a26-a509-4906-a0e3-3e1ee92f5227", + "conversation_id": "7a2f446c-2efa-0c56-08dd-07cdc5cac19b" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784286971190, + "updatedAtMs": 1784286980344, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784286980344, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Convention retenue : **epoch-ms numérique suffixé `Ms` partout**.\n\n- Store persistant : `pairedAtMs`, `lastSeenAtMs`, `expiresAtMs` si le code éphémère est sérialisé dans un DTO/debug, type `number`/`u64`, pas chaîne.\n- DTO UI : mêmes noms `pairedAtMs`, `lastSeenAtMs`, `expiresAtMs`, type `number`.\n\nQui bouge : **backend + frontend**, mais minimalement. Le backend doit arrêter d’émettre des chaînes epoch non suffixées et renommer les champs. Le frontend doit consommer `*AtMs` via `new Date(ms)` et ajuster fixtures/types.\n\nJe retire donc mon ISO initial au profit de la convention codebase. Ne gardez pas `pairedAt: \"1784286814274\"` : c’est le pire des trois contrats.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "7bfd1e08-df74-4f00-8fc4-477ebaa4076b", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f", + "ticket_id": "5fa078ba-48c8-457d-af31-339a3453eae3", + "conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784287004702, + "updatedAtMs": 1784287215598, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784287215598, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Changement appliqué sur le contrat backend/Tauri uniquement. Le scan suivant ne retourne plus rien sur les anciens champs ou timestamps chaîne du ticket :\n\n`rg \"pairedAt\\b|lastSeenAt\\b|expiresAt\\b|paired_at:\\s*String|last_seen_at:\\s*String|expires_at:\\s*String|\\.paired_at\\b|\\.last_seen_at\\b|expires_at\\b.*to_string\" crates/domain crates/application crates/infrastructure crates/web-server crates/app-tauri`\n\n**Diff Ciblé**\n```diff\n- pub paired_at: String,\n- pub last_seen_at: String,\n+ pub paired_at_ms: u64,\n+ pub last_seen_at_ms: u64,\n\n- let now = self.clock.now_millis().max(0).to_string();\n+ let now = self.clock.now_millis().max(0) as u64;\n\n- let last_seen = device.last_seen_at.parse::().unwrap_or(0);\n- device.last_seen_at = now.to_string();\n+ if now.saturating_sub(device.last_seen_at_ms) < LAST_SEEN_TOUCH_THROTTLE_MS { ... }\n+ device.last_seen_at_ms = now;\n\n- pub expires_at: String,\n+ pub expires_at_ms: u64,\n\n- expires_at: expires_at_ms.to_string(),\n+ expires_at_ms: expires_at_ms.max(0) as u64,\n\n- assert!(row.contains_key(\"pairedAt\"));\n- assert!(row.contains_key(\"lastSeenAt\"));\n+ assert!(row[\"pairedAtMs\"].is_number());\n+ assert!(row[\"lastSeenAtMs\"].is_number());\n```\n\nFichiers touchés pour ce lot :\n`crates/domain/src/device.rs`, `crates/application/src/device.rs`, `crates/infrastructure/src/store/device_session.rs`, `crates/web-server/src/lib.rs`, `crates/app-tauri/src/commands.rs`.\n\n**`cargo test -p domain -p application -p infrastructure -p web-server`**\n```text\nFinished `test` profile [unoptimized + debuginfo] target(s) in 16.46s\n...\ntest result: FAILED. 275 passed; 10 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.37s\nerror: test failed, to rerun pass `-p infrastructure --lib`\n```\n\nLes 10 échecs sont tous des binds interdits par le sandbox :\n```text\nbind: Os { code: 1, kind: PermissionDenied, message: \"Operation not permitted\" }\n```\nÉchecs concernés : tests `session::openai_compat::*` et `session::tests::factory_routes_openai_compatible_to_http_session`. Rien d’autre. Cargo s’arrête sur `infrastructure`, donc les tests runtime `web-server` ne sont pas atteints dans cette commande combinée.\n\nVérification additionnelle utile :\n```text\ncargo test -p domain -p application\ntest result: ok\n```\n\n**`cargo clippy -p web-server --all-targets`**\n```text\nFinished `dev` profile [unoptimized + debuginfo] target(s) in 8.71s\nexit code: 0\n```\n\nWarnings restants uniquement préexistants dans les dépendances vérifiées par la commande : `domain` 4, `application` 8, `infrastructure` 8, `backend` 1. Aucun warning `web-server`.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "47c10f1a-751c-4c91-b279-529325151408", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "af7f86da-76bc-48e1-9900-71f45a624800", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "af7f86da-76bc-48e1-9900-71f45a624800", + "ticket_id": "6bc07012-5994-4856-9b8b-5feb8e94c7a5", + "conversation_id": "09b15ec3-ce2f-0af2-2903-ef699df9de41" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784287140993, + "updatedAtMs": 1784287409664, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784287409664, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Le contrat `*AtMs: number` est appliqué, et je l'ai vérifié contre le Rust livré plutôt que contre mes mocks : **DevBackend est parfaitement aligné, aucune dérive à signaler.**\n\n## Ce que j'ai changé\n\n`domain/index.ts` : `pairedAt`/`lastSeenAt`/`expiresAt` → `pairedAtMs`/`lastSeenAtMs`/`expiresAtMs`, typés `number`.\n\n`formatActivity.ts` : consomme des `number` via `new Date(ms)`. J'ai suivi ta consigne à la lettre — **pas de `parseInstant()` tolérant**. J'ai même retiré les gardes `Number.isNaN` qui existaient : avec un contrat univoque et un type `number`, ils ne protégeaient plus de rien et laissaient croire qu'un autre encodage était possible. Le test « degrades to a dash on an unparseable instant » est parti avec eux, remplacé par un test qui vérifie qu'un epoch-ms brut rend une vraie date.\n\nRenommage propagé dans `DevicesScreen`, `PairingCodePanel`, `MockDeviceGateway`, et les deux gateways.\n\n**Les fixtures portent maintenant des nombres** (`Date.now()`, `.getTime()`) au lieu d'ISO, dans `DevicesScreen.test.tsx` et `formatActivity.test.ts`. C'est le point que tu as désigné comme le plus important, et c'était bien la racine du vert trompeur.\n\nSur le décompte (point 4) : j'ai ajouté un test dédié qui vérifie qu'un code fraîchement généré **n'affiche pas « Ce code a expiré. »**. Je l'ai écrit au niveau de l'écran, pas du formateur, parce que le bug d'origine ne vivait pas dans le formateur seul — il vivait dans la chaîne mock → panneau → formateur. Un test du formateur seul l'aurait raté exactement comme les précédents.\n\n## Vérification côté Rust — tout est aligné\n\nJ'ai lu les DTO livrés plutôt que de faire confiance à mes mocks :\n\n- `DeviceDto` : `paired_at_ms: u64`, `last_seen_at_ms: u64`, avec `rename_all = \"camelCase\"` → `pairedAtMs`, `lastSeenAtMs`. ✓\n- `PairingCodeDto` : `expires_at_ms: u64`, même casing → `expiresAtMs`. ✓\n- Domaine `PairedDevice` : `paired_at_ms: u64`, plus aucun `.to_string()` ; l'arithmétique de throttle `last_seen_at_ms` est redevenue numérique. ✓\n\nDeux bonus que cette lecture a confirmés : `GET /api/devices` renvoie bien `{\"devices\": [...]}`, et **les cinq routes que j'avais devinées en F2 sont exactement celles que B3 a livrées** (`/api/pairing-code`, `/api/devices`, `/api/devices/revoke-all`, `/api/devices/{id}/rename`, `/api/devices/{id}/revoke`). L'écart n°1 que je t'avais signalé est donc clos.\n\nDu coup j'ai appliqué ton principe « pas de tolérance défensive » **aussi à mes deux gateways** : ils acceptaient encore un tableau nu en plus de `{devices}`, une forme que le backend n'émet pas. Supprimé des deux côtés. Même raisonnement que pour `parseInstant()`, je ne l'ai pas laissé traîner par cohérence.\n\n## Commande et sortie réelle\n\n`cd frontend && npx vitest run` — **premier passage rouge**, honnêtement :\n\n```\n FAIL src/features/permissions/permissions.test.tsx > PermissionsPanel > saves project defaults\n Test Files 1 failed | 90 passed (91)\n Tests 1 failed | 847 passed (848)\n```\n\nVérifié : `git diff HEAD -- src/features/permissions` est vide (je n'y ai pas touché), le test passe seul, et le second passage complet est vert. C'est un **test flaky préexistant** (1371 ms, sensible au timing), pas une régression de ce lot — mais il mérite un ticket, il finira par mordre en CI.\n\nSecond passage :\n\n```\n Test Files 91 passed (91)\n Tests 848 passed (848)\n Duration 15.69s\n```\n\n`npx tsc --noEmit` sort en 0. `npm run build:bundle` + `npm run test:bundle-transport` :\n\n```\ndist: __IDEA_TRANSPORT__=\"tauri\" (1 marker)\ndist-web: __IDEA_TRANSPORT__=\"http\" (1 marker)\n```\n\nUn `grep` final ne trouve plus aucun `pairedAt`/`lastSeenAt`/`expiresAt` sans suffixe `Ms`, ni aucune mention d'ISO-8601 dans la surface Appareils.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "02eda7ec-4b10-4e15-8f96-979c3f7d3f80", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "ticket_id": "bb4a0123-588b-48a4-92b9-1cfd00255a71", + "conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4" + }, + "state": "running", + "wakePolicy": "recordOnly", + "createdAtMs": 1784287477331, + "updatedAtMs": 1784287477331, "deadlineMs": null, "result": null, "completionDelivered": false diff --git a/.ideai/tickets/77/carnet.md b/.ideai/tickets/77/carnet.md new file mode 100644 index 0000000..9379bf4 --- /dev/null +++ b/.ideai/tickets/77/carnet.md @@ -0,0 +1,153 @@ +--- +issueRef: "#77" +version: 4 +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedAt: 1784287453378 +--- +# Carnet #77 — cadrage UX + Architecture (2026-07-17) + +Le design produit est dans la description du ticket. Ce carnet porte le **cadrage**, validé UX puis Architect. UX est passée avant Architect (la forme conditionnait les DTO). + +--- + +## Arbitrages Main + +**Affichage du code — groupement visuel uniquement.** UX proposait `AB12-CD34`. **Refusé.** Le code réel n'a pas de séparateur, et la normalisation frontend de #75 supprime les espaces mais **pas les tirets** : l'utilisateur recopierait le tiret et l'appairage échouerait. Groupement en deux blocs de 4 par espacement typographique/CSS, valeur copiée = `AB12CD34`. + +**#76 est absorbé dans ce lot** (Architect, confirmé Main) : normalisation serveur (espaces, tirets, puis uppercase) avant comparaison. La sécurité ne doit pas dépendre de la normalisation frontend. + +**`already_used` n'est pas exposé** (tranché par Architect, périmètre sécurité). L'API répond `invalid_or_expired` pour code absent, expiré, déjà consommé, remplacé ou incorrect — pas d'oracle pour un attaquant. Les logs internes peuvent distinguer ; le DTO public jamais. + +**TTL du code : 10 minutes** (UX, confirmé Architect). + +--- + +## Spec UX — surface « Appareils » + +Écran unique, mobile-first, monté dans **l'UI web ET l'app desktop**. Navigation : `Paramètres → Appareils`. Même écran, même vocabulaire, mêmes actions des deux côtés. + +**Liste** — par ligne : nom (niveau principal), badge `Cet appareil` pour la session courante, dernière activité (`Actif à l'instant`, `Aujourd'hui à 14:32`, `Hier`, puis date courte), date d'appairage en secondaire discret. **Jamais** de User-Agent brut, jamais d'IP. + +**Appairer** — action primaire en haut. Panneau : code grand et lisible, bouton `Copier`, `Saisissez ce code sur le nouvel appareil.`, `Expire dans 10 min`. À expiration : `Ce code a expiré.` + bouton `Générer un nouveau code`, sans alarmisme. Après succès : `Nouvel appareil appairé`, liste rafraîchie, highlight temporaire 2-3 s. + +**Nom d'appareil** — saisi sur le **nouvel appareil** au moment de l'appairage, prérempli par dérivation lisible (`iPhone`, `Chrome sur Windows`), obligatoire, 1-40 caractères, renommable ensuite via le menu `⋯`. + +**Révocation** — menu `⋯` → `Révoquer`. Confirmation : `Révoquer cet appareil ?` / `Il devra être appairé à nouveau pour accéder à IdeA.` Si c'est l'appareil courant : `Vous serez déconnecté immédiatement.` puis coupure et redirection vers l'écran d'appairage. `Révoquer tous les appareils` en bas, confirmation forte, n'exige pas de lire la liste, inclut l'appareil courant. + +**Erreurs** — `Code invalide ou expiré.` (incorrect/expiré/utilisé, message unique) ; `Trop de tentatives. Réessayez dans quelques minutes avec un nouveau code.` + +--- + +## Cadrage Architecture + +### Persistance — sous-domaine d'accès mono-utilisateur + +- **domain** : `PairedDevice`, `DeviceId`, `SessionTokenHash`, `DeviceName`, `PairingCode`. +- **application** : `PairDevice`, `ListDevices`, `RenameDevice`, `RevokeDevice`, `RevokeAllDevices`, `AuthenticateSession`, `TouchDevice`. +- **port** : `DeviceSessionStore` — **adapter** : `FsDeviceSessionStore`. + +Store dans `{app_data_dir}/security/devices.json`, **pas dans `.ideai/` projet** : ce sont des accès à l'instance, pas des données du repo. + +```json +{ "version": 1, "devices": [ { "deviceId": "uuid", "name": "…", "pairedAt": "…", "lastSeenAt": "…", "sessionTokenHash": "sha256:…" } ] } +``` + +**Token** : 32 octets CSPRNG. Hash disque `SHA-256("idea-session-v1\0" || token_bytes)`, hex préfixé par l'algo, **comparaison constant-time**. + +**Pas d'Argon2id** — décision Architect, et elle est juste : ce n'est pas un mot de passe faible mais un secret aléatoire 256-bit. Un KDF lent n'ajoute rien contre la préimage et coûte du CPU serveur à chaque requête. Le point dur reste : jamais de token en clair sur disque. + +**DTO UI** : `{ deviceId, name, pairedAt, lastSeenAt, isCurrentDevice }`. Pas d'IP, pas de User-Agent. + +### Code éphémère + +**Ne va pas dans le store persistant** — reste en mémoire process, mais sort du `pairing_code: String` statique de `ServerState`. État : `{ code_hash, expires_at, generation_id, used }`. Consommation atomique `consume(code, now) -> Valid | InvalidOrExpired`. Générer invalide toujours le précédent. Sans `--new-code`, **aucun code n'existe au boot**. + +Desktop Tauri : commande appelant le **même use case via le composition root, pas via HTTP**. + +À supprimer : `EmbeddedServerHandle.pairing_code` / `EmbeddedServerStatusDto.pairing_code` comme source permanente. + +### Révocation → WebSockets (durcissement n°4) + +Le domaine publie `DeviceRevoked { device_id }` / `AllDevicesRevoked`. Le web-server (adapter transport) tient un `ActiveConnectionRegistry: device_id -> Vec`. `AuthenticateSession` retourne `AuthenticatedDevice { device_id, token_hash }` — **pas un booléen** — pour que `run_ws_connection` s'enregistre sous le bon `device_id`. + +Séquence : le use case supprime du store → publie l'événement → l'adapter observe → ferme les WS concernés via un canal `shutdown` que la boucle sélectionne en parallèle de `read_ws_frame`, puis unregister `ws_pty_bridge` comme aujourd'hui. `OutputBridge` reste un outil de flux PTY, **jamais le mécanisme d'autorisation**. + +### Rate-limit + +Port `PairAttemptLimiter` dans l'application, appelé par `PairDevice` **avant** validation du code. Adapter prod `InMemoryPairAttemptLimiter`, adapter test à horloge fixe. Clé `RateLimitKey { origin, route }` — **pas de headers HTTP dans le port**, la résolution proxy reste dans l'adapter. Repères : 5 échecs/min par origine, 30 échecs/min global. + +### Cookie + +`HttpOnly`, `SameSite=Strict`, `Secure` selon config existante, `Path=/`, `Max-Age≈34560000` (400 j). **Renouvellement glissant** sur toute requête authentifiée réussie. Seule la révocation invalide. `lastSeenAt` **throttlé** (≤ 1 écriture / 5 min / appareil). + +### `--new-code` + +Bool dans `ServerArgs`. Après `ServerState` créé et listener bindé : génération + impression **uniquement dans ce cas**. Ne touche pas le store, ne crée pas d'appareil, ne change aucune config. + +--- + +## Lots + +| Lot | Contenu | Dépend de | +|---|---|---| +| **B1** | `DeviceSessionStore`, hash tokens, `PairDevice {code, name}`, cookie Max-Age + renouvellement, normalisation serveur (#76) | — (**bloquant**) | +| **B2** | Code éphémère, `POST /api/pairing-code`, `--new-code`, suppression impression au boot, TTL/usage unique/invalidation, `invalid_or_expired` | B1 | +| **B3** | Endpoints list/rename/revoke/revoke-all/logout, event `DeviceRevoked`, registre WS par `device_id`, fermeture immédiate + cleanup `OutputBridge` | B1 | +| **B4** | Port `PairAttemptLimiter`, adapter mémoire + tests horloge, réponse `rate_limited` | parallèle à B2 | +| **F1** | `POST /api/pair {code, name}`, normalisation frontend espaces **et tirets**, erreurs | contrat B1 | +| **F2** | Écran Appareils partagé web/desktop | DTO figés | + +Écart UX ↔ Architecture : aucun sur la forme des DTO. + +--- + +## État d'avancement (2026-07-17) + +- **B1 — vert, vérifié par Main** (le rapport de DevBackend a été perdu par un timeout de rendez-vous ; les tests ont été rejoués indépendamment). `cargo test -p domain -p application -p infrastructure -p web-server` intégralement vert. Couverture réelle constatée : store (round-trip, fichier absent, JSON corrompu), `session_token_hash_is_prefixed_sha256_and_verifies_constant_time`, `authenticated_invoke_renews_session_cookie_max_age`, `pairing_code_normalization_removes_spaces_hyphens_and_uppercases`, `touch_device_is_throttled_to_five_minutes`, validation du nom. +- **F1 + F2 — verts sur mock**, 91 fichiers / 841 tests. `tsc --noEmit` à 0. Garde-fous `no-direct-invoke` et `desktop-only` verts. `test:bundle-transport` confirme `dist` = tauri, `dist-web` = http. +- **B2 + B4 — en cours.** +- **B3 — à lancer.** + +--- + +## Contrats figés par Main après F1/F2 + +F1/F2 ont été livrés avant B3. DevFrontend a dû déduire des contrats que le cadrage ne nommait pas ; **je les fige tels quels**, ils sont cohérents avec les routes déjà nommées par Architect. **B3 s'y conforme** — si divergence, c'est le backend qui s'aligne, pas le frontend. + +### Routes REST + +| Route | Usage | +|---|---| +| `GET /api/devices` | liste | +| `POST /api/devices/{id}/rename` | renommage | +| `POST /api/devices/{id}/revoke` | révocation d'un appareil | +| `POST /api/devices/revoke-all` | révocation totale | +| `POST /api/pairing-code` | génération d'un code | +| `POST /api/pair` | `{code, name}` | + +Raisonnement retenu (DevFrontend, validé Main) : la surface d'authentification ne peut pas vivre sur le RPC générique `/api/invoke`, qui est lui-même auth-gated. Elle vit donc sur des routes dédiées, comme `/api/pair` et `/api/logout` aujourd'hui. + +### Commandes Tauri + +`list_devices`, `create_pairing_code`, `rename_device`, `revoke_device`, `revoke_all_devices`. + +### Notification d'appairage — polling, pas d'event + +UX demande « nouvel appareil appairé → liste rafraîchie + highlight ». **Décision Main : pas d'event `DevicePaired` côté B3.** L'UI poll `listDevices()` toutes les 3 s **uniquement pendant que le panneau de code est ouvert** (≤ 10 min, jamais en régime permanent), pour un acte rare. Un event domaine routé jusqu'au WS live serait plus propre en théorie, mais ajoute une surface et du code client pour un gain invisible. Option notée, non retenue. + +### Choix de sécurité frontend à préserver + +**Le message d'erreur du serveur n'est jamais réaffiché** : l'UI mappe sur le code (`invalid_or_expired`, `rate_limited`) vers les libellés figés. Si un backend écrivait un jour « code déjà utilisé » dans `message`, un pont qui forwarde réintroduirait l'oracle que le ticket interdit. Le mapping rend la fuite structurellement impossible plutôt que d'en faire une affaire de discipline. **Un test verrouille ce point — ne pas le contourner.** + +### Écart B1 → corrigé en B2 + +`new_session_token()` concaténait deux UUID v4 au lieu d'un CSPRNG (aucune crate `rand` dans `web-server`). Pas une faille — UUID v4 tire de `getrandom`, 244 bits effectifs — mais écart au cadrage sur un chemin de sécurité, et construction que le prochain lecteur devrait re-vérifier pour se rassurer. Correction demandée dans B2. + +### Hors périmètre, consigné ailleurs + +- Langue mélangée des sections Settings desktop (« Appareils » vs « AI Profiles », « Deployment ») → **#78**, décision UX de fond (règle de langue de l'UI, éventuel i18n). +- `ConfirmDialog` laissée locale à la feature plutôt qu'ajoutée au design system : décision UX/Architect, pas un effet de bord de ce ticket. + +### Dette de topologie à traiter par Git + +**B1, F1 et F2 ont été implémentés directement sur `develop`**, sans branche de feature — erreur de cadrage de Main, qui a lancé les devs sans passer par Git. Le travail est sain et non commité. **Git doit ranger ça avant tout commit**, sachant que `develop` porte déjà 37 commits non poussés. diff --git a/.ideai/tickets/77/issue.md b/.ideai/tickets/77/issue.md new file mode 100644 index 0000000..965182c --- /dev/null +++ b/.ideai/tickets/77/issue.md @@ -0,0 +1,77 @@ +--- +id: "eecf77ee-dfcb-436c-aa5f-0c7033c7bdfa" +number: 77 +title: "Appairage : appareils enregistrés persistants, révocables, et code éphémère à usage unique" +status: "qa" +priority: "high" +sprint: null +links: [{"target":"#76","kind":"relatesTo"},{"target":"#75","kind":"relatesTo"},{"target":"#68","kind":"relatesTo"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +createdAt: 1784279214815 +updatedAt: 1784287453378 +version: 4 +--- +## Besoin utilisateur + +Deux constats live (téléphone, instance exposée derrière reverse proxy) : + +1. Le code d'appairage est redemandé **à chaque ouverture de la page**. Impraticable. +2. Le code étant affiché sur la machine, un appairage est impossible quand l'utilisateur n'est pas chez lui. + +## Cause racine du #1 — trois couches, confirmées + +- **Le cookie de session n'a ni `Max-Age` ni `Expires`** (`crates/web-server/src/lib.rs:1629`). C'est un cookie de session au sens navigateur : détruit à la fermeture. Sur mobile, purge agressive en arrière-plan → réappairage quasi systématique. **C'est la cause directe.** +- **Les sessions vivent en mémoire** (`sessions: Mutex>`, ligne 422). Un redémarrage désappaire tous les appareils, même avec un cookie persistant. +- **Le code d'appairage est régénéré à chaque démarrage** (`new_pairing_code()` appelé dans `with_core`, ligne 437). Le code noté hier ne vaut plus rien. + +## Design validé (discussion utilisateur ↔ Main, 2026-07-17) + +### Appareils + +- Un **appareil** appairé est persistant, nommé, listable, révocable. Session valide **indéfiniment jusqu'à révocation**. +- Le **serveur est la source de vérité** de la durée de vie. Le cookie n'est qu'un porteur, renouvelé à chaque visite (le plafond navigateur réel est ~400 jours côté Chrome — « indéfini » se tient côté serveur, pas côté cookie). +- Vocabulaire figé : **« appareils »**, jamais « utilisateurs ». Ce design est **mono-utilisateur assumé** : tous les appareils ont les pleins pouvoirs, pas de comptes, pas de mots de passe, pas de permissions différenciées. Le jour où plusieurs personnes seront nécessaires, ce sera un autre chantier — le vocabulaire ne doit pas avoir menti entre-temps. + +### Code d'appairage + +- **Éphémère, à usage unique, généré à la demande.** TTL court (5-10 min à arbitrer). Générer un nouveau code invalide le précédent. +- **Suppression du code statique au démarrage**, y compris l'`eprintln!("IdeA pairing code: …")` (ligne 619) : un secret permanent qui part dans stderr/journald/toute capture de sortie. Après ce lot, aucun secret au repos — un code n'existe que quand il est demandé. +- Génération : depuis l'**app desktop** (serveur embarqué, accès direct à l'état) et depuis l'**UI web déjà appairée**. +- **Pas de commande CLI de génération, pas de socket de contrôle, pas de store partagé entre processus.** Décision explicite : supprime l'IPC, la concurrence d'écriture CLI↔serveur et la classe de bugs associée. +- **Flag `--new-code`** au lancement du serveur headless : affiche un code au démarrage. C'est le **bootstrap du premier lancement** et la **trappe de secours** quand plus aucune UI n'est accessible. Le flag ne doit **rien rendre persistant** (laissé par mégarde dans une unit systemd, il ne doit pas transformer chaque redémarrage en distribution de code). +- Le code réapparaît dans stderr avec `--new-code` : risque résiduel accepté, car TTL court + usage unique rendent sans valeur un log qui fuite plus tard. + +### Condition de validité du design + +**L'écran de gestion des appareils doit vivre dans l'UI web, pas seulement dans l'app desktop.** Sinon, sur une installation headless, générer un code impose un redémarrage — donc couper PTY, agents en cours et WebSockets — à chaque nouvel appareil. Avec l'écran dans l'UI web, la boucle se ferme : `--new-code` donne le premier appareil, tout le reste se gère depuis cet appareil, et `--new-code` redevient une trappe de secours. + +### Accès distant — trou assumé + +Le design ne couvre **pas** l'appairage d'un appareil neuf à distance : générer un code suppose une UI appairée ou un accès à la machine. Le filet est **SSH** (se connecter, relancer avec `--new-code`). Assumé consciemment, acceptable tant que les appareils persistent réellement — le cas devient rare. **Passkey/WebAuthn gardée en réserve, hors périmètre de ce lot.** + +## Durcissements non négociables + +1. **TTL sur le code** — pas seulement l'usage unique. Un code généré et jamais consommé qui reste valide pour toujours recrée le problème d'aujourd'hui. +2. **Limite de tentatives sur `POST /api/pair`** — 8 caractères hex = 4,3e9 combinaisons, brute-forçable en quelques semaines à débit soutenu contre un code permanent. TTL **et** rate-limit, pas l'un ou l'autre. +3. **Tokens de session hachés sur disque, jamais en clair.** Le store `.ideai/` part dans les backups et les synchros ; en clair il donne un accès shell. À traiter comme un fichier de mots de passe. +4. **La révocation doit couper les WebSockets déjà ouverts.** Un WS établi ne revalide jamais le cookie : révoquer un appareil qui a un PTY ouvert le laisserait piloter la machine. Une révocation qui ne coupe pas la connexion vivante n'est pas une révocation. + +## Surface de gestion + +Lister les appareils (nom, date d'appairage, dernière activité), les révoquer un par un, et **révoquer tout** — pour le jour où un téléphone est perdu et où lister avant d'agir n'est pas souhaitable. + +## Points d'entrée code + +- `crates/web-server/src/lib.rs` : `ServerState` (418-482), `create_session`/`has_session`/`revoke_session` (469-484), `eprintln!` du code (619), comparaison du code (1612), pose du cookie (1629-1640), `logout_response` (1643), `new_pairing_code` (2220), `new_session_token` (2230). +- `crates/app-tauri/src/embedded_server.rs` : exposition `pairing_code` (97, 334). +- `frontend/src/features/web/PairingScreen.tsx`, `frontend/src/adapters/http/webSession.ts`. + +## DoD + +- Un appareil appairé le reste après fermeture du navigateur **et** après redémarrage du serveur. +- Aucun code d'appairage n'existe au repos ; un code demandé expire et ne sert qu'une fois. +- Révocation effective immédiatement, WebSockets vivants inclus. +- Écran de gestion accessible depuis l'UI web **et** l'app desktop. +- Validation live : téléphone appairé une fois, toujours connecté le lendemain après redémarrage de l'AppImage. \ No newline at end of file diff --git a/.ideai/tickets/78/carnet.md b/.ideai/tickets/78/carnet.md new file mode 100644 index 0000000..4758a67 --- /dev/null +++ b/.ideai/tickets/78/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#78" +version: 1 +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedAt: 1784284746767 +--- diff --git a/.ideai/tickets/78/issue.md b/.ideai/tickets/78/issue.md new file mode 100644 index 0000000..1a2d880 --- /dev/null +++ b/.ideai/tickets/78/issue.md @@ -0,0 +1,34 @@ +--- +id: "8d8bc65a-50f6-4fab-bb02-486b6145205f" +number: 78 +title: "Settings desktop : sections en langues mélangées (« Appareils » à côté de « AI Profiles », « Deployment »)" +status: "open" +priority: "low" +sprint: null +links: [{"target":"#77","kind":"relatesTo"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +createdAt: 1784284746767 +updatedAt: 1784284746767 +version: 1 +--- +## Constat + +Relevé par DevFrontend pendant l'implémentation de #77 (F2), hors périmètre de ce ticket. + +L'écran Settings du desktop mélange les langues dans sa liste de sections : la nouvelle section **« Appareils »** (vocabulaire figé par UX pour #77, non négociable) voisine avec **« AI Profiles »** et **« Deployment »**. + +Le problème n'est pas la section ajoutée par #77 — c'est que la surface Settings n'a pas de règle de langue établie. Le français de « Appareils » est correct et cohérent avec le reste de l'UI produit (`Appairer cet appareil`, `Code d'appairage`) ; ce sont les sections préexistantes qui sont en anglais. + +## Pourquoi c'est un ticket UX et pas un fix de libellé + +Trancher demande une décision de fond qui dépasse un renommage : quelle est la langue de l'UI d'IdeA, et est-elle uniforme ou dépend-elle de la surface ? La réponse engage toutes les surfaces existantes, pas seulement Settings, et conditionne un éventuel besoin d'i18n. + +## Attendu + +UX tranche la règle de langue de l'UI, puis on aligne les sections de Settings dessus. + +## Périmètre + +Frontend pur si la décision est « tout en français » ou « tout en anglais ». Devient un chantier distinct si la réponse est « il faut de l'i18n ». \ No newline at end of file diff --git a/.ideai/tickets/79/carnet.md b/.ideai/tickets/79/carnet.md new file mode 100644 index 0000000..e4ae099 --- /dev/null +++ b/.ideai/tickets/79/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#79" +version: 1 +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedAt: 1784287450082 +--- diff --git a/.ideai/tickets/79/issue.md b/.ideai/tickets/79/issue.md new file mode 100644 index 0000000..800af11 --- /dev/null +++ b/.ideai/tickets/79/issue.md @@ -0,0 +1,47 @@ +--- +id: "05a70dc5-fd28-4c58-8ba1-be6058ae3cfc" +number: 79 +title: "Test flaky : PermissionsPanel « saves project defaults » échoue par intermittence" +status: "open" +priority: "low" +sprint: null +links: [] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +createdAt: 1784287450082 +updatedAt: 1784287450082 +version: 1 +--- +## Constat + +Relevé par DevFrontend pendant #77, **sans rapport avec ce ticket**. + +`frontend/src/features/permissions/permissions.test.tsx` → `PermissionsPanel > saves project defaults` échoue par intermittence sur une exécution complète de la suite : + +``` + FAIL src/features/permissions/permissions.test.tsx > PermissionsPanel > saves project defaults + Test Files 1 failed | 90 passed (91) + Tests 1 failed | 847 passed (848) +``` + +## Pourquoi ce n'est pas une régression de #77 + +Vérifié au moment du constat : + +- `git diff HEAD -- src/features/permissions` est **vide** — le lot #77 n'a pas touché cette surface. +- Le test **passe seul**. +- Le passage complet suivant est **vert** (848/848), sans modification entre les deux. +- Durée du test : ~1371 ms — sensible au timing. + +## Pourquoi ça mérite un ticket quand même + +Un test qui passe deux fois sur trois est pire qu'un test rouge : il apprend à l'équipe à relancer plutôt qu'à lire. Il finira par mordre en CI, où l'on ne relance pas toujours, et il érode la confiance dans une suite dont ce chantier a montré qu'elle est le principal garde-fou. + +## Attendu + +Identifier la source du non-déterminisme (timers, attente implicite, état partagé entre tests) et rendre le test déterministe. **Ne pas le neutraliser ni augmenter un timeout pour le faire taire** : si le comportement testé est réellement dépendant du timing, c'est le comportement qu'il faut regarder. + +## Périmètre + +Frontend, tests. \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 4fa6919..ad1925c 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 77 + "nextNumber": 80 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 1a94f21..b311171 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -798,6 +798,36 @@ "sprint": null, "assignedAgentIds": [], "updatedAt": 1784277540034 + }, + { + "issueRef": "#77", + "path": "77", + "title": "Appairage : appareils enregistrés persistants, révocables, et code éphémère à usage unique", + "status": "qa", + "priority": "high", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1784287453378 + }, + { + "issueRef": "#78", + "path": "78", + "title": "Settings desktop : sections en langues mélangées (« Appareils » à côté de « AI Profiles », « Deployment »)", + "status": "open", + "priority": "low", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1784284746767 + }, + { + "issueRef": "#79", + "path": "79", + "title": "Test flaky : PermissionsPanel « saves project defaults » échoue par intermittence", + "status": "open", + "priority": "low", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1784287450082 } ] } \ No newline at end of file