Merge feature/ticket77-paired-devices into develop

Intègre le ticket #77 : l'appairage repose désormais sur des appareils
persistants, nommés et révocables, derrière un code éphémère à usage
unique (TTL 10 min) au lieu d'un code permanent imprimé sur stdout.

Ferme aussi la dette #76 : la normalisation du code passe côté serveur,
là où #75 ne pouvait que la masquer depuis le frontend.

Suites rejouées avant merge, toutes vertes :
- cargo test -p domain -p application -p infrastructure -p web-server :
  exit 0, 89 suites, 1686 tests.
- frontend : 91 fichiers, 848 tests, tsc exit 0.
- garde-fou de bundle : dist = tauri, dist-web = http.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 13:27:17 +02:00
57 changed files with 5362 additions and 323 deletions

File diff suppressed because one or more lines are too long

153
.ideai/tickets/77/carnet.md Normal file
View File

@ -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<ConnectionHandle>`. `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.

View File

@ -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<HashSet<String>>`, 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.

View File

@ -0,0 +1,6 @@
---
issueRef: "#78"
version: 1
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784284746767
---

View File

@ -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 ».

View File

@ -0,0 +1,6 @@
---
issueRef: "#79"
version: 1
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784287450082
---

View File

@ -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.

View File

@ -1,3 +1,3 @@
{ {
"nextNumber": 77 "nextNumber": 80
} }

View File

@ -798,6 +798,36 @@
"sprint": null, "sprint": null,
"assignedAgentIds": [], "assignedAgentIds": [],
"updatedAt": 1784277540034 "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
} }
] ]
} }

8
Cargo.lock generated
View File

@ -101,6 +101,7 @@ dependencies = [
"domain", "domain",
"serde", "serde",
"serde_json", "serde_json",
"subtle",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"uuid", "uuid",
@ -905,8 +906,11 @@ name = "domain"
version = "0.3.0" version = "0.3.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"hex",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"subtle",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"uuid", "uuid",
@ -5247,10 +5251,14 @@ dependencies = [
"bytes", "bytes",
"cookie", "cookie",
"domain", "domain",
"getrandom 0.3.4",
"hex",
"http", "http",
"http-body-util", "http-body-util",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"subtle",
"tokio", "tokio",
"uuid", "uuid",
] ]

View File

@ -22,6 +22,10 @@ thiserror = "2"
async-trait = "0.1" async-trait = "0.1"
futures-util = "0.3" futures-util = "0.3"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] }
hex = "0.4"
sha2 = "0.10"
subtle = "2"
getrandom = "0.3"
# Local git via libgit2. Network features (https/ssh → openssl) are off for L8: # Local git via libgit2. Network features (https/ssh → openssl) are off for L8:
# only local operations (status/commit/branch/checkout/log) are in scope; remote # only local operations (status/commit/branch/checkout/log) are in scope; remote
# push/pull and static vendoring for the AppImage are deferred to L9/L11. # push/pull and static vendoring for the AppImage are deferred to L9/L11.

View File

@ -16,12 +16,12 @@ use application::{
DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput,
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput,
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
LaunchAgentInput, ListAgentsInput, ListLayoutsInput, ListMemoriesInput, LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput,
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime, ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput,
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
ReconcileLiveStateInput, RenameLayoutInput, ResolveAgentPermissionsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
ResolveMemoryLinksInput, RotateConversationLogInput, SetActiveLayoutInput, ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput,
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput, UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput,
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
@ -66,7 +66,8 @@ use crate::embedded_server::{
}; };
use crate::pty::{PtyBridge, PtyChunk}; use crate::pty::{PtyBridge, PtyChunk};
use crate::state::{AppState, FocusedProjectDto}; use crate::state::{AppState, FocusedProjectDto};
use domain::{SkillRef, SkillScope}; use domain::{DeviceId, SkillRef, SkillScope};
use uuid::Uuid;
/// `health` — trivial command validating the full IPC pipeline /// `health` — trivial command validating the full IPC pipeline
/// (frontend gateway → invoke → command → use case → ports → event relay). /// (frontend gateway → invoke → command → use case → ports → event relay).
@ -149,6 +150,157 @@ pub async fn embedded_server_stop(
state.embedded_server.stop().await state.embedded_server.stop().await
} }
/// `embedded_server_generate_pairing_code` — generate a desktop-owned ephemeral code.
///
/// # Errors
/// Returns an [`ErrorDto`] when the embedded server is not running.
#[tauri::command]
pub fn embedded_server_generate_pairing_code(
state: State<'_, AppState>,
) -> Result<web_server::PairingCodeDto, ErrorDto> {
state.embedded_server.generate_pairing_code()
}
/// Device row exposed to the desktop settings surface.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceDto {
/// Device id.
pub device_id: String,
/// User-facing name.
pub name: String,
/// Pairing timestamp as epoch milliseconds.
pub paired_at_ms: u64,
/// Last successful access timestamp as epoch milliseconds.
pub last_seen_at_ms: u64,
/// Whether this is the current authenticated web device.
pub is_current_device: bool,
}
/// List response for paired devices.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceListDto {
/// Paired devices.
pub devices: Vec<DeviceDto>,
}
/// Rename request for paired devices.
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenameDeviceRequestDto {
/// Device id.
pub device_id: String,
/// New name.
pub name: String,
}
/// Revoke request for one paired device.
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RevokeDeviceRequestDto {
/// Device id.
pub device_id: String,
}
fn parse_device_id(raw: &str) -> Result<DeviceId, ErrorDto> {
Uuid::parse_str(raw)
.map(DeviceId::from_uuid)
.map_err(|_| ErrorDto {
code: "INVALID".to_owned(),
message: "invalid device id".to_owned(),
})
}
/// `list_devices` — list paired devices through the desktop composition root.
///
/// # Errors
/// Returns an [`ErrorDto`] when the device store cannot be read.
#[tauri::command]
pub async fn list_devices(state: State<'_, AppState>) -> Result<DeviceListDto, ErrorDto> {
let output = state
.list_devices
.execute(ListDevicesInput {
current_device_id: None,
})
.await
.map_err(ErrorDto::from)?;
Ok(DeviceListDto {
devices: output
.devices
.into_iter()
.map(|device| DeviceDto {
device_id: device.device_id.to_string(),
name: device.name,
paired_at_ms: device.paired_at_ms,
last_seen_at_ms: device.last_seen_at_ms,
is_current_device: device.is_current_device,
})
.collect(),
})
}
/// `create_pairing_code` — generate an ephemeral pairing code via the embedded server state.
///
/// # Errors
/// Returns an [`ErrorDto`] when the embedded server is not running.
#[tauri::command]
pub fn create_pairing_code(
state: State<'_, AppState>,
) -> Result<web_server::PairingCodeDto, ErrorDto> {
state.embedded_server.generate_pairing_code()
}
/// `rename_device` — rename a paired device.
///
/// # Errors
/// Returns an [`ErrorDto`] for invalid input or store failures.
#[tauri::command]
pub async fn rename_device(
request: RenameDeviceRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
state
.rename_device
.execute(RenameDeviceInput {
device_id: parse_device_id(&request.device_id)?,
name: request.name,
})
.await
.map_err(ErrorDto::from)
}
/// `revoke_device` — revoke one paired device.
///
/// # Errors
/// Returns an [`ErrorDto`] for invalid input or store failures.
#[tauri::command]
pub async fn revoke_device(
request: RevokeDeviceRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
state
.revoke_device
.execute(RevokeDeviceInput {
device_id: parse_device_id(&request.device_id)?,
})
.await
.map_err(ErrorDto::from)
}
/// `revoke_all_devices` — revoke every paired device.
///
/// # Errors
/// Returns an [`ErrorDto`] when the device store cannot be rewritten.
#[tauri::command]
pub async fn revoke_all_devices(state: State<'_, AppState>) -> Result<(), ErrorDto> {
state
.revoke_all_devices
.execute()
.await
.map_err(ErrorDto::from)
}
/// `create_project` — create a project from a root: init `.ideai/`, register it. /// `create_project` — create a project from a root: init `.ideai/`, register it.
/// ///
/// # Errors /// # Errors

View File

@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex};
use backend::BackendCore; use backend::BackendCore;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use web_server::{EmbeddedServerHandle, ServerConfig, TrustedProxy}; use web_server::{EmbeddedServerHandle, PairingCodeDto, ServerConfig, TrustedProxy};
use crate::dto::ErrorDto; use crate::dto::ErrorDto;
@ -93,8 +93,6 @@ pub struct EmbeddedServerStatusDto {
pub public_url: Option<String>, pub public_url: Option<String>,
/// Reverse-proxy upstream URL derived from settings. /// Reverse-proxy upstream URL derived from settings.
pub upstream_url: Option<String>, pub upstream_url: Option<String>,
/// Runtime pairing code, never persisted.
pub pairing_code: Option<String>,
/// Last failure, when state is `failed`. /// Last failure, when state is `failed`.
pub error: Option<ErrorDto>, pub error: Option<ErrorDto>,
} }
@ -278,6 +276,21 @@ impl EmbeddedServerController {
} }
} }
/// Generates a new ephemeral pairing code on the running embedded server.
///
/// # Errors
/// Returns an [`ErrorDto`] if the embedded server is not running.
pub fn generate_pairing_code(&self) -> Result<PairingCodeDto, ErrorDto> {
let inner = self.inner.lock().expect("embedded server mutex poisoned");
let Some(handle) = inner.handle.as_ref() else {
return Err(ErrorDto {
code: "UNAVAILABLE".to_owned(),
message: "embedded server is not running".to_owned(),
});
};
Ok(handle.generate_pairing_code())
}
/// Stops the embedded server. Idempotent when already stopped. /// Stops the embedded server. Idempotent when already stopped.
/// ///
/// # Errors /// # Errors
@ -331,10 +344,6 @@ fn status_from_inner(inner: &EmbeddedServerInner) -> EmbeddedServerStatusDto {
local_url: inner.handle.as_ref().map(|handle| handle.url().to_owned()), local_url: inner.handle.as_ref().map(|handle| handle.url().to_owned()),
public_url: inner.public_url.clone(), public_url: inner.public_url.clone(),
upstream_url: inner.upstream_url.clone(), upstream_url: inner.upstream_url.clone(),
pairing_code: inner
.handle
.as_ref()
.map(|handle| handle.pairing_code().to_owned()),
error, error,
} }
} }
@ -436,6 +445,7 @@ fn server_config_from_settings(
trusted_proxies, trusted_proxies,
app_data_dir, app_data_dir,
web_root, web_root,
new_code: false,
}; };
config.validate().map_err(invalid_error)?; config.validate().map_err(invalid_error)?;
Ok(config) Ok(config)
@ -771,7 +781,9 @@ mod tests {
.as_deref() .as_deref()
.is_some_and(|url| url.starts_with("http://127.0.0.1:"))); .is_some_and(|url| url.starts_with("http://127.0.0.1:")));
assert_ne!(first.local_url.as_deref(), Some("http://127.0.0.1:0")); assert_ne!(first.local_url.as_deref(), Some("http://127.0.0.1:0"));
assert_eq!(first.pairing_code, second.pairing_code); let pairing = controller.generate_pairing_code().unwrap();
assert_eq!(pairing.ttl_seconds, 600);
assert_eq!(pairing.code.len(), 8);
let stopped = controller.stop().await.unwrap(); let stopped = controller.stop().await.unwrap();
@ -780,7 +792,6 @@ mod tests {
EmbeddedServerStatusStateDto::Stopped EmbeddedServerStatusStateDto::Stopped
)); ));
assert!(stopped.local_url.is_none()); assert!(stopped.local_url.is_none());
assert!(stopped.pairing_code.is_none());
} }
#[tokio::test] #[tokio::test]
@ -804,6 +815,5 @@ mod tests {
assert_eq!(err.code, "INVALID"); assert_eq!(err.code, "INVALID");
assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed)); assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed));
assert!(status.pairing_code.is_none());
} }
} }

View File

@ -292,6 +292,12 @@ pub fn run() {
commands::embedded_server_status, commands::embedded_server_status,
commands::embedded_server_start, commands::embedded_server_start,
commands::embedded_server_stop, commands::embedded_server_stop,
commands::embedded_server_generate_pairing_code,
commands::list_devices,
commands::create_pairing_code,
commands::rename_device,
commands::revoke_device,
commands::revoke_all_devices,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running IdeA Tauri application"); .expect("error while running IdeA Tauri application");

View File

@ -12,6 +12,7 @@ thiserror = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
subtle = { workspace = true }
# `v5` derives stable reference-profile ids from a fixed namespace (catalogue). # `v5` derives stable reference-profile ids from a fixed namespace (catalogue).
uuid = { workspace = true } uuid = { workspace = true }
# `time` feature only : borne le rendez-vous synchrone `send_blocking` (§17.4). # `time` feature only : borne le rendez-vous synchrone `send_blocking` (§17.4).

View File

@ -0,0 +1,482 @@
//! Paired-device session use cases.
use std::sync::Arc;
use async_trait::async_trait;
use domain::events::DomainEvent;
use domain::ports::{Clock, DeviceSessionStore, EventBus, IdGenerator, StoreError};
use domain::{AuthenticatedDevice, DeviceId, DeviceName, PairedDevice, SessionTokenHash};
use subtle::ConstantTimeEq;
use crate::error::AppError;
const LAST_SEEN_TOUCH_THROTTLE_MS: u64 = 5 * 60 * 1000;
/// Input for [`PairDevice`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PairDeviceInput {
/// Name supplied by the new device.
pub name: String,
/// Hash of the freshly issued session token.
pub session_token_hash: SessionTokenHash,
}
/// Output for [`PairDevice`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PairDeviceOutput {
/// Created paired device.
pub device: PairedDevice,
}
/// Validates a pairing request and persists the new paired device.
pub struct PairDevice {
store: Arc<dyn DeviceSessionStore>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
}
impl PairDevice {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(
store: Arc<dyn DeviceSessionStore>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
) -> Self {
Self { store, ids, clock }
}
/// Executes device pairing.
///
/// # Errors
/// Returns [`AppError::Invalid`] for an invalid name and [`AppError::Store`] for
/// persistence failures.
pub async fn execute(&self, input: PairDeviceInput) -> Result<PairDeviceOutput, AppError> {
let now = self.clock.now_millis().max(0) as u64;
let device = PairedDevice {
device_id: DeviceId::from_uuid(self.ids.new_uuid()),
name: DeviceName::new(input.name).map_err(|err| AppError::Invalid(err.to_string()))?,
paired_at_ms: now,
last_seen_at_ms: now,
session_token_hash: input.session_token_hash,
};
let mut devices = self.store.load_devices().await?;
devices.push(device.clone());
self.store.save_devices(&devices).await?;
Ok(PairDeviceOutput { device })
}
}
/// Rate-limit key for pairing attempts.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RateLimitKey {
/// Resolved origin identity, typically the peer IP after transport-layer proxy handling.
pub origin: String,
/// Logical route being limited.
pub route: String,
}
/// Pairing-attempt limiter port.
#[async_trait]
pub trait PairAttemptLimiter: Send + Sync {
/// Returns whether a new failed pairing attempt may be processed.
async fn check(&self, key: RateLimitKey) -> Result<PairAttemptDecision, StoreError>;
/// Records one failed pairing attempt for the key and the global bucket.
async fn record_failure(&self, key: RateLimitKey) -> Result<(), StoreError>;
}
/// Result of a rate-limit check.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PairAttemptDecision {
/// Attempt can proceed.
Allowed,
/// Attempt is blocked by one of the buckets.
RateLimited,
}
/// Input for [`AuthenticateSession`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthenticateSessionInput {
/// Raw token bytes decoded from the session cookie.
pub token_bytes: Vec<u8>,
}
/// Authenticates a persisted device session.
pub struct AuthenticateSession {
store: Arc<dyn DeviceSessionStore>,
}
impl AuthenticateSession {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>) -> Self {
Self { store }
}
/// Returns the authenticated device, including its token hash, when valid.
///
/// # Errors
/// Returns [`AppError::Store`] for persistence failures.
pub async fn execute(
&self,
input: AuthenticateSessionInput,
) -> Result<Option<AuthenticatedDevice>, AppError> {
Ok(self.store.authenticate_token(&input.token_bytes).await?)
}
}
/// Input for [`TouchDevice`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TouchDeviceInput {
/// Device to mark as seen.
pub device_id: DeviceId,
}
/// Output for [`TouchDevice`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TouchDeviceOutput {
/// Whether the store was rewritten.
pub updated: bool,
}
/// Device row exposed to presentation layers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeviceView {
/// Persistent device id.
pub device_id: DeviceId,
/// User-facing name.
pub name: String,
/// Pairing timestamp as epoch milliseconds.
pub paired_at_ms: u64,
/// Last successful authenticated access timestamp as epoch milliseconds.
pub last_seen_at_ms: u64,
/// Whether this row is the authenticated current device.
pub is_current_device: bool,
}
/// Input for [`ListDevices`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListDevicesInput {
/// Current device id, when the driving adapter has an authenticated device.
pub current_device_id: Option<DeviceId>,
}
/// Output for [`ListDevices`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListDevicesOutput {
/// Paired devices.
pub devices: Vec<DeviceView>,
}
/// Lists paired devices without exposing transport-sensitive fields.
pub struct ListDevices {
store: Arc<dyn DeviceSessionStore>,
}
impl ListDevices {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>) -> Self {
Self { store }
}
/// Executes the listing.
///
/// # Errors
/// Returns [`AppError::Store`] for persistence failures.
pub async fn execute(&self, input: ListDevicesInput) -> Result<ListDevicesOutput, AppError> {
let devices = self
.store
.load_devices()
.await?
.into_iter()
.map(|device| DeviceView {
device_id: device.device_id,
name: device.name.as_str().to_owned(),
paired_at_ms: device.paired_at_ms,
last_seen_at_ms: device.last_seen_at_ms,
is_current_device: Some(device.device_id) == input.current_device_id,
})
.collect();
Ok(ListDevicesOutput { devices })
}
}
/// Input for [`RenameDevice`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenameDeviceInput {
/// Device to rename.
pub device_id: DeviceId,
/// New user-facing name.
pub name: String,
}
/// Renames a paired device.
pub struct RenameDevice {
store: Arc<dyn DeviceSessionStore>,
}
impl RenameDevice {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>) -> Self {
Self { store }
}
/// Executes the rename.
///
/// # Errors
/// Returns [`AppError::Invalid`] for an invalid name, [`AppError::NotFound`]
/// when the device does not exist, and [`AppError::Store`] for persistence failures.
pub async fn execute(&self, input: RenameDeviceInput) -> Result<(), AppError> {
let new_name =
DeviceName::new(input.name).map_err(|err| AppError::Invalid(err.to_string()))?;
let mut devices = self.store.load_devices().await?;
let Some(device) = devices
.iter_mut()
.find(|device| device.device_id == input.device_id)
else {
return Err(AppError::NotFound("device not found".to_owned()));
};
device.name = new_name;
self.store.save_devices(&devices).await?;
Ok(())
}
}
/// Input for [`RevokeDevice`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RevokeDeviceInput {
/// Device to revoke.
pub device_id: DeviceId,
}
/// Revokes one paired device and publishes a revocation event after persistence.
pub struct RevokeDevice {
store: Arc<dyn DeviceSessionStore>,
events: Arc<dyn EventBus>,
}
impl RevokeDevice {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>, events: Arc<dyn EventBus>) -> Self {
Self { store, events }
}
/// Executes the revocation.
///
/// # Errors
/// Returns [`AppError::NotFound`] when the device does not exist and
/// [`AppError::Store`] for persistence failures.
pub async fn execute(&self, input: RevokeDeviceInput) -> Result<(), AppError> {
if !self.store.remove_device(input.device_id).await? {
return Err(AppError::NotFound("device not found".to_owned()));
}
self.events.publish(DomainEvent::DeviceRevoked {
device_id: input.device_id,
});
Ok(())
}
}
/// Revokes every paired device and publishes a global revocation event.
pub struct RevokeAllDevices {
store: Arc<dyn DeviceSessionStore>,
events: Arc<dyn EventBus>,
}
impl RevokeAllDevices {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>, events: Arc<dyn EventBus>) -> Self {
Self { store, events }
}
/// Executes global revocation.
///
/// # Errors
/// Returns [`AppError::Store`] for persistence failures.
pub async fn execute(&self) -> Result<(), AppError> {
self.store.save_devices(&[]).await?;
self.events.publish(DomainEvent::AllDevicesRevoked);
Ok(())
}
}
/// Updates `lastSeenAtMs`, throttled to avoid rewriting the JSON on every request.
pub struct TouchDevice {
store: Arc<dyn DeviceSessionStore>,
clock: Arc<dyn Clock>,
}
impl TouchDevice {
/// Builds the use case from injected ports.
#[must_use]
pub fn new(store: Arc<dyn DeviceSessionStore>, clock: Arc<dyn Clock>) -> Self {
Self { store, clock }
}
/// Executes the touch.
///
/// # Errors
/// Returns [`AppError::Store`] for persistence failures.
pub async fn execute(&self, input: TouchDeviceInput) -> Result<TouchDeviceOutput, AppError> {
let now = self.clock.now_millis().max(0) as u64;
let mut devices = self.store.load_devices().await?;
let Some(device) = devices
.iter_mut()
.find(|device| device.device_id == input.device_id)
else {
return Ok(TouchDeviceOutput { updated: false });
};
if now.saturating_sub(device.last_seen_at_ms) < LAST_SEEN_TOUCH_THROTTLE_MS {
return Ok(TouchDeviceOutput { updated: false });
}
device.last_seen_at_ms = now;
self.store.save_devices(&devices).await?;
Ok(TouchDeviceOutput { updated: true })
}
}
/// Normalizes user-entered pairing codes server-side.
#[must_use]
pub fn normalize_pairing_code(code: &str) -> String {
code.chars()
.filter(|ch| !ch.is_whitespace() && *ch != '-')
.flat_map(char::to_uppercase)
.collect()
}
/// Compares pairing codes after normalization without early-exit byte comparison.
#[must_use]
pub fn pairing_codes_equal(candidate: &str, expected: &str) -> bool {
let candidate = normalize_pairing_code(candidate);
let expected = normalize_pairing_code(expected);
let bytes_equal: bool = candidate.as_bytes().ct_eq(expected.as_bytes()).into();
bytes_equal && candidate.len() == expected.len()
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use domain::ports::StoreError;
use std::sync::Mutex;
use uuid::Uuid;
#[derive(Default)]
struct FakeStore {
devices: Mutex<Vec<PairedDevice>>,
saves: Mutex<usize>,
}
#[async_trait]
impl DeviceSessionStore for FakeStore {
async fn load_devices(&self) -> Result<Vec<PairedDevice>, StoreError> {
Ok(self.devices.lock().unwrap().clone())
}
async fn save_devices(&self, devices: &[PairedDevice]) -> Result<(), StoreError> {
*self.devices.lock().unwrap() = devices.to_vec();
*self.saves.lock().unwrap() += 1;
Ok(())
}
}
struct FixedClock(i64);
impl domain::ports::Clock for FixedClock {
fn now_millis(&self) -> i64 {
self.0
}
}
struct FixedIds;
impl domain::ports::IdGenerator for FixedIds {
fn new_uuid(&self) -> Uuid {
Uuid::from_u128(7)
}
}
#[test]
fn pairing_code_normalization_removes_spaces_hyphens_and_uppercases() {
assert_eq!(normalize_pairing_code(" ab12-cd34 "), "AB12CD34");
assert!(pairing_codes_equal(" ab12-cd34 ", "AB12CD34"));
assert!(!pairing_codes_equal("AB12CD35", "AB12CD34"));
}
#[tokio::test]
async fn pair_device_validates_name_and_persists_device() {
let store = Arc::new(FakeStore::default());
let use_case = PairDevice::new(
Arc::clone(&store) as Arc<dyn DeviceSessionStore>,
Arc::new(FixedIds),
Arc::new(FixedClock(1234)),
);
let output = use_case
.execute(PairDeviceInput {
name: "Phone".to_owned(),
session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]),
})
.await
.unwrap();
assert_eq!(output.device.name.as_str(), "Phone");
assert_eq!(store.load_devices().await.unwrap().len(), 1);
}
#[tokio::test]
async fn pair_device_rejects_empty_name() {
let use_case = PairDevice::new(
Arc::new(FakeStore::default()),
Arc::new(FixedIds),
Arc::new(FixedClock(1234)),
);
let err = use_case
.execute(PairDeviceInput {
name: " ".to_owned(),
session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]),
})
.await
.unwrap_err();
assert_eq!(err.code(), "INVALID");
}
#[tokio::test]
async fn touch_device_is_throttled_to_five_minutes() {
let device = PairedDevice {
device_id: DeviceId::from_uuid(Uuid::from_u128(7)),
name: DeviceName::new("Phone").unwrap(),
paired_at_ms: 1000,
last_seen_at_ms: 1000,
session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]),
};
let store = Arc::new(FakeStore::default());
store.save_devices(&[device]).await.unwrap();
*store.saves.lock().unwrap() = 0;
let use_case = TouchDevice::new(
Arc::clone(&store) as Arc<dyn DeviceSessionStore>,
Arc::new(FixedClock(
(1000 + LAST_SEEN_TOUCH_THROTTLE_MS - 1) as i64,
)),
);
let output = use_case
.execute(TouchDeviceInput {
device_id: DeviceId::from_uuid(Uuid::from_u128(7)),
})
.await
.unwrap();
assert!(!output.updated);
assert_eq!(*store.saves.lock().unwrap(), 0);
}
}

View File

@ -14,6 +14,7 @@
pub mod agent; pub mod agent;
pub mod background; pub mod background;
pub mod conversation; pub mod conversation;
pub mod device;
pub mod diag; pub mod diag;
pub mod embedder; pub mod embedder;
pub mod error; pub mod error;
@ -65,6 +66,13 @@ pub use conversation::{
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn, ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView, RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView,
}; };
pub use device::{
normalize_pairing_code, pairing_codes_equal, AuthenticateSession, AuthenticateSessionInput,
DeviceView, ListDevices, ListDevicesInput, ListDevicesOutput, PairAttemptDecision,
PairAttemptLimiter, PairDevice, PairDeviceInput, PairDeviceOutput, RateLimitKey, RenameDevice,
RenameDeviceInput, RevokeAllDevices, RevokeDevice, RevokeDeviceInput, TouchDevice,
TouchDeviceInput, TouchDeviceOutput,
};
pub use embedder::{ pub use embedder::{
CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput, CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput,
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines, DismissChoice, DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines, DismissChoice,

View File

@ -302,6 +302,14 @@ pub enum DomainEventDto {
/// Version synced to. /// Version synced to.
to: u64, to: u64,
}, },
/// A paired device was revoked.
#[serde(rename_all = "camelCase")]
DeviceRevoked {
/// Device id.
device_id: String,
},
/// Every paired device was revoked.
AllDevicesRevoked,
/// A skill was assigned to (or unassigned from) an agent. /// A skill was assigned to (or unassigned from) an agent.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
SkillAssigned { SkillAssigned {
@ -884,6 +892,10 @@ impl From<&DomainEvent> for DomainEventDto {
agent_id: agent_id.to_string(), agent_id: agent_id.to_string(),
to: to.get(), to: to.get(),
}, },
DomainEvent::DeviceRevoked { device_id } => Self::DeviceRevoked {
device_id: device_id.to_string(),
},
DomainEvent::AllDevicesRevoked => Self::AllDevicesRevoked,
DomainEvent::SkillAssigned { DomainEvent::SkillAssigned {
agent_id, agent_id,
skill_id, skill_id,

View File

@ -13,43 +13,46 @@ use std::sync::{Arc, Mutex};
use application::{ use application::{
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent, AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive,
ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed, CloseProject, CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion,
CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CloneOpenCodeProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant,
CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint,
DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory,
DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate,
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions,
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent,
ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles,
ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, ListProjects,
LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
OpenTicketAssistant, OrchestratorService, PermissionProjectorRegistry, ProposeContext, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex,
ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState,
ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice,
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions,
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RotateConversationLog, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RevokeAllDevices, RevokeDevice,
SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout, RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService,
SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions,
UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use domain::ports::{ use domain::ports::{
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore, AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner, AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
BackgroundTaskStore, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore, BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner,
ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore,
StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, WakeError, WakeReason, StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, WakeError, WakeReason,
WindowStateStore, WindowStateStore,
}; };
@ -69,14 +72,15 @@ use infrastructure::{
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink, embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector, BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe, CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore,
FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsIssueStore, FsLiveStateStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher,
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSkillStore,
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, FsSprintStore, FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer,
HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore,
LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox,
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard,
StructuredSessionFactory, SystemClock, SystemMillisClock, TicketAssistantEnvironmentPreparer, StructuredSessionFactory, SystemClock, SystemMillisClock, TicketAssistantEnvironmentPreparer,
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
@ -798,6 +802,24 @@ impl AgentResumer for AppAgentResumer {
pub struct BackendCore { pub struct BackendCore {
/// Trivial health use case validating the end-to-end wiring. /// Trivial health use case validating the end-to-end wiring.
pub health: Arc<HealthUseCase>, pub health: Arc<HealthUseCase>,
/// Pair a persistent device session.
pub pair_device: Arc<PairDevice>,
/// Limits failed pairing attempts.
pub pair_attempt_limiter: Arc<dyn PairAttemptLimiter>,
/// Authenticate a persistent device session.
pub authenticate_session: Arc<AuthenticateSession>,
/// Throttled update of the authenticated device last-seen timestamp.
pub touch_device: Arc<TouchDevice>,
/// List paired devices.
pub list_devices: Arc<ListDevices>,
/// Rename a paired device.
pub rename_device: Arc<RenameDevice>,
/// Revoke one paired device.
pub revoke_device: Arc<RevokeDevice>,
/// Revoke every paired device.
pub revoke_all_devices: Arc<RevokeAllDevices>,
/// Paired-device store port, exposed for legacy logout revocation.
pub device_session_store: Arc<dyn DeviceSessionStore>,
/// Create a project (init `.ideai/`, register it). /// Create a project (init `.ideai/`, register it).
pub create_project: Arc<CreateProject>, pub create_project: Arc<CreateProject>,
/// Open a project (load meta + manifest). /// Open a project (load meta + manifest).
@ -1134,11 +1156,21 @@ impl BackendCore {
Arc::clone(&fs) as Arc<dyn FileSystem>, Arc::clone(&fs) as Arc<dyn FileSystem>,
app_data_dir.to_string_lossy().into_owned(), app_data_dir.to_string_lossy().into_owned(),
)); ));
let device_session_store = Arc::new(FsDeviceSessionStore::new(
Arc::clone(&fs) as Arc<dyn FileSystem>,
app_data_dir.to_string_lossy().into_owned(),
));
let pair_attempt_limiter = Arc::new(InMemoryPairAttemptLimiter::new(
Arc::clone(&clock) as Arc<dyn Clock>
));
// Port-typed handles for injection. // Port-typed handles for injection.
let fs_port = Arc::clone(&fs) as Arc<dyn FileSystem>; let fs_port = Arc::clone(&fs) as Arc<dyn FileSystem>;
let store_port = Arc::clone(&store) as Arc<dyn ProjectStore>; let store_port = Arc::clone(&store) as Arc<dyn ProjectStore>;
let window_state_port = Arc::clone(&window_state_store) as Arc<dyn WindowStateStore>; let window_state_port = Arc::clone(&window_state_store) as Arc<dyn WindowStateStore>;
let device_session_port = Arc::clone(&device_session_store) as Arc<dyn DeviceSessionStore>;
let pair_attempt_limiter_port =
Arc::clone(&pair_attempt_limiter) as Arc<dyn PairAttemptLimiter>;
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>; let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
// --- Use cases (ports injected as Arc<dyn Port>) --- // --- Use cases (ports injected as Arc<dyn Port>) ---
@ -1147,6 +1179,27 @@ impl BackendCore {
Arc::clone(&ids) as Arc<dyn IdGenerator>, Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&events_port), Arc::clone(&events_port),
)); ));
let pair_device = Arc::new(PairDevice::new(
Arc::clone(&device_session_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&clock) as Arc<dyn Clock>,
));
let authenticate_session =
Arc::new(AuthenticateSession::new(Arc::clone(&device_session_port)));
let touch_device = Arc::new(TouchDevice::new(
Arc::clone(&device_session_port),
Arc::clone(&clock) as Arc<dyn Clock>,
));
let list_devices = Arc::new(ListDevices::new(Arc::clone(&device_session_port)));
let rename_device = Arc::new(RenameDevice::new(Arc::clone(&device_session_port)));
let revoke_device = Arc::new(RevokeDevice::new(
Arc::clone(&device_session_port),
Arc::clone(&events_port),
));
let revoke_all_devices = Arc::new(RevokeAllDevices::new(
Arc::clone(&device_session_port),
Arc::clone(&events_port),
));
let create_project = Arc::new(CreateProject::new( let create_project = Arc::new(CreateProject::new(
Arc::clone(&store_port), Arc::clone(&store_port),
@ -2316,6 +2369,15 @@ impl BackendCore {
Self { Self {
health, health,
pair_device,
pair_attempt_limiter: Arc::clone(&pair_attempt_limiter_port),
authenticate_session,
touch_device,
list_devices,
rename_device,
revoke_device,
revoke_all_devices,
device_session_store: Arc::clone(&device_session_port),
create_project, create_project,
open_project, open_project,
close_project, close_project,

View File

@ -12,6 +12,9 @@ serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
hex = { workspace = true }
sha2 = { workspace = true }
subtle = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true } tokio = { workspace = true }

184
crates/domain/src/device.rs Normal file
View File

@ -0,0 +1,184 @@
//! Persistent paired-device access model.
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use thiserror::Error;
use uuid::Uuid;
const SESSION_TOKEN_HASH_PREFIX: &str = "sha256:";
const SESSION_TOKEN_HASH_CONTEXT: &[u8] = b"idea-session-v1\0";
const SESSION_TOKEN_HASH_HEX_LEN: usize = 64;
/// Unique identifier for a paired device.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct DeviceId(Uuid);
impl DeviceId {
/// Builds a device id from an existing UUID.
#[must_use]
pub const fn from_uuid(value: Uuid) -> Self {
Self(value)
}
/// Returns the wrapped UUID.
#[must_use]
pub const fn as_uuid(self) -> Uuid {
self.0
}
}
impl std::fmt::Display for DeviceId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
/// Device display name supplied by the newly paired device.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct DeviceName(String);
impl DeviceName {
/// Validates and stores a device name.
///
/// Names are required and limited to 40 Unicode scalar values.
pub fn new(value: impl Into<String>) -> Result<Self, DeviceError> {
let value = value.into().trim().to_owned();
let len = value.chars().count();
if len == 0 {
return Err(DeviceError::InvalidName(
"device name is required".to_owned(),
));
}
if len > 40 {
return Err(DeviceError::InvalidName(
"device name must be 40 characters or fewer".to_owned(),
));
}
Ok(Self(value))
}
/// Returns the stored name.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Hash of a random 256-bit session token.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionTokenHash(String);
impl SessionTokenHash {
/// Hashes raw token bytes as `SHA-256("idea-session-v1\0" || token_bytes)`.
#[must_use]
pub fn from_token_bytes(token_bytes: &[u8]) -> Self {
let mut hasher = Sha256::new();
hasher.update(SESSION_TOKEN_HASH_CONTEXT);
hasher.update(token_bytes);
let digest = hasher.finalize();
Self(format!(
"{SESSION_TOKEN_HASH_PREFIX}{}",
hex::encode(digest)
))
}
/// Validates an already persisted hash string.
pub fn new(value: impl Into<String>) -> Result<Self, DeviceError> {
let value = value.into();
validate_hash_string(&value)?;
Ok(Self(value))
}
/// Returns the persisted hash string.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
/// Constant-time verification of raw token bytes against this stored hash.
#[must_use]
pub fn verify_token_bytes(&self, token_bytes: &[u8]) -> bool {
let candidate = Self::from_token_bytes(token_bytes);
self.constant_time_eq(&candidate)
}
/// Constant-time equality for two valid persisted token hashes.
#[must_use]
pub fn constant_time_eq(&self, other: &Self) -> bool {
self.0.as_bytes().ct_eq(other.0.as_bytes()).into()
}
}
/// A paired device persisted in the app-data security store.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PairedDevice {
/// Persistent device id.
pub device_id: DeviceId,
/// User-facing device name.
pub name: DeviceName,
/// Pairing timestamp as epoch milliseconds.
pub paired_at_ms: u64,
/// Last successful authenticated access timestamp as epoch milliseconds.
pub last_seen_at_ms: u64,
/// Hash of the bearer session token.
pub session_token_hash: SessionTokenHash,
}
/// Successful session authentication result.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthenticatedDevice {
/// Authenticated device id.
pub device_id: DeviceId,
/// Matched token hash.
pub token_hash: SessionTokenHash,
}
/// Device-domain validation errors.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum DeviceError {
/// Invalid device name.
#[error("invalid device name: {0}")]
InvalidName(String),
/// Invalid persisted token hash.
#[error("invalid session token hash")]
InvalidSessionTokenHash,
}
fn validate_hash_string(value: &str) -> Result<(), DeviceError> {
let Some(hex) = value.strip_prefix(SESSION_TOKEN_HASH_PREFIX) else {
return Err(DeviceError::InvalidSessionTokenHash);
};
if hex.len() != SESSION_TOKEN_HASH_HEX_LEN || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(DeviceError::InvalidSessionTokenHash);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_token_hash_is_prefixed_sha256_and_verifies_constant_time() {
let token = [7_u8; 32];
let hash = SessionTokenHash::from_token_bytes(&token);
assert!(hash.as_str().starts_with("sha256:"));
assert_eq!(hash.as_str().len(), "sha256:".len() + 64);
assert!(hash.verify_token_bytes(&token));
assert!(!hash.verify_token_bytes(&[8_u8; 32]));
assert!(hash.constant_time_eq(&SessionTokenHash::new(hash.as_str()).unwrap()));
}
#[test]
fn device_name_is_required_and_limited() {
assert!(DeviceName::new("phone").is_ok());
assert!(DeviceName::new(" ").is_err());
assert!(DeviceName::new("a".repeat(41)).is_err());
}
}

View File

@ -2,6 +2,7 @@
//! presentation layer (ARCHITECTURE §3.2). //! presentation layer (ARCHITECTURE §3.2).
use crate::conversation::ConversationParty; use crate::conversation::ConversationParty;
use crate::device::DeviceId;
use crate::ids::{ use crate::ids::{
AgentId, IssueId, LocalModelServerId, ProfileId, ProjectId, SessionId, SkillId, SprintId, AgentId, IssueId, LocalModelServerId, ProfileId, ProjectId, SessionId, SkillId, SprintId,
TaskId, TemplateId, TaskId, TemplateId,
@ -268,6 +269,13 @@ pub enum DomainEvent {
/// Version it was brought up to. /// Version it was brought up to.
to: TemplateVersion, to: TemplateVersion,
}, },
/// A paired device was revoked and any live transport for it must be closed.
DeviceRevoked {
/// Revoked device.
device_id: DeviceId,
},
/// All paired devices were revoked and every live paired transport must close.
AllDevicesRevoked,
/// A skill was assigned to (or unassigned from) an agent. /// A skill was assigned to (or unassigned from) an agent.
SkillAssigned { SkillAssigned {
/// The agent whose skill set changed. /// The agent whose skill set changed.

View File

@ -35,6 +35,7 @@ pub mod agent_tool_policy;
pub mod background_task; pub mod background_task;
pub mod conversation; pub mod conversation;
pub mod conversation_log; pub mod conversation_log;
pub mod device;
pub mod error; pub mod error;
pub mod events; pub mod events;
pub mod fileguard; pub mod fileguard;
@ -140,6 +141,10 @@ pub use conversation_log::{
ROTATE_AFTER_BYTES, ROTATE_AFTER_TURNS, ROTATE_AFTER_BYTES, ROTATE_AFTER_TURNS,
}; };
pub use device::{
AuthenticatedDevice, DeviceError, DeviceId, DeviceName, PairedDevice, SessionTokenHash,
};
pub use fileguard::{ pub use fileguard::{
is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource, is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource,
OrchestratorDesignation, ReadLease, WriteLease, OrchestratorDesignation, ReadLease, WriteLease,

View File

@ -34,6 +34,7 @@ use crate::agent_tool_policy::AgentToolPolicy;
use crate::background_task::{ use crate::background_task::{
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
}; };
use crate::device::{AuthenticatedDevice, DeviceId, PairedDevice};
use crate::events::DomainEvent; use crate::events::DomainEvent;
use crate::ids::{ use crate::ids::{
AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId, AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId,
@ -1688,6 +1689,45 @@ pub trait EmbedderPromptStore: Send + Sync {
) -> Result<(), StoreError>; ) -> Result<(), StoreError>;
} }
/// Persists paired devices and their session-token hashes.
#[async_trait]
pub trait DeviceSessionStore: Send + Sync {
/// Loads all paired devices. A missing store is represented as an empty list.
async fn load_devices(&self) -> Result<Vec<PairedDevice>, StoreError>;
/// Replaces the persisted device list atomically enough for the filesystem
/// adapter's single-process use case.
async fn save_devices(&self, devices: &[PairedDevice]) -> Result<(), StoreError>;
/// Removes one device by id.
async fn remove_device(&self, device_id: DeviceId) -> Result<bool, StoreError> {
let mut devices = self.load_devices().await?;
let before = devices.len();
devices.retain(|device| device.device_id != device_id);
if devices.len() != before {
self.save_devices(&devices).await?;
return Ok(true);
}
Ok(false)
}
/// Authenticates a session token against persisted token hashes.
async fn authenticate_token(
&self,
token_bytes: &[u8],
) -> Result<Option<AuthenticatedDevice>, StoreError> {
for device in self.load_devices().await? {
if device.session_token_hash.verify_token_bytes(token_bytes) {
return Ok(Some(AuthenticatedDevice {
device_id: device.device_id,
token_hash: device.session_token_hash,
}));
}
}
Ok(None)
}
}
/// Reads/writes agent `.md` contexts and the project manifest, within a project. /// Reads/writes agent `.md` contexts and the project manifest, within a project.
#[async_trait] #[async_trait]
pub trait AgentContextStore: Send + Sync { pub trait AgentContextStore: Send + Sync {

View File

@ -28,6 +28,7 @@ pub mod issues;
pub mod mailbox; pub mod mailbox;
pub mod model_server; pub mod model_server;
pub mod orchestrator; pub mod orchestrator;
pub mod pair_attempt_limiter;
pub mod permission; pub mod permission;
pub mod process; pub mod process;
pub mod pty; pub mod pty;
@ -77,6 +78,7 @@ pub use orchestrator::{
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle, process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
REQUESTS_SUBDIR, REQUESTS_SUBDIR,
}; };
pub use pair_attempt_limiter::InMemoryPairAttemptLimiter;
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector}; pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
pub use process::LocalProcessSpawner; pub use process::LocalProcessSpawner;
pub use pty::PortablePtyAdapter; pub use pty::PortablePtyAdapter;
@ -96,9 +98,9 @@ pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT};
pub use store::{ pub use store::{
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector, embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore, AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore, FsMemoryStore, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore,
FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, FsTemplateStore, FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall,
StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
}; };

View File

@ -0,0 +1,153 @@
//! In-memory pairing-attempt limiter.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use application::{PairAttemptDecision, PairAttemptLimiter, RateLimitKey};
use async_trait::async_trait;
use domain::ports::{Clock, StoreError};
const WINDOW_MS: i64 = 60_000;
const PER_ORIGIN_LIMIT: usize = 5;
const GLOBAL_LIMIT: usize = 30;
/// Process-local limiter for failed pairing attempts.
pub struct InMemoryPairAttemptLimiter {
clock: Arc<dyn Clock>,
inner: Mutex<LimiterState>,
}
#[derive(Default)]
struct LimiterState {
by_origin: HashMap<String, Vec<i64>>,
global: Vec<i64>,
}
impl InMemoryPairAttemptLimiter {
/// Creates a limiter using the supplied clock.
#[must_use]
pub fn new(clock: Arc<dyn Clock>) -> Self {
Self {
clock,
inner: Mutex::new(LimiterState::default()),
}
}
}
#[async_trait]
impl PairAttemptLimiter for InMemoryPairAttemptLimiter {
async fn check(&self, key: RateLimitKey) -> Result<PairAttemptDecision, StoreError> {
let now = self.clock.now_millis().max(0);
let cutoff = now.saturating_sub(WINDOW_MS);
let mut inner = self.inner.lock().expect("pair limiter mutex poisoned");
prune(&mut inner.global, cutoff);
let origin = origin_key(&key);
let origin_bucket = inner.by_origin.entry(origin).or_default();
prune(origin_bucket, cutoff);
if origin_bucket.len() >= PER_ORIGIN_LIMIT || inner.global.len() >= GLOBAL_LIMIT {
return Ok(PairAttemptDecision::RateLimited);
}
Ok(PairAttemptDecision::Allowed)
}
async fn record_failure(&self, key: RateLimitKey) -> Result<(), StoreError> {
let now = self.clock.now_millis().max(0);
let cutoff = now.saturating_sub(WINDOW_MS);
let mut inner = self.inner.lock().expect("pair limiter mutex poisoned");
prune(&mut inner.global, cutoff);
inner.global.push(now);
let origin = origin_key(&key);
let origin_bucket = inner.by_origin.entry(origin).or_default();
prune(origin_bucket, cutoff);
origin_bucket.push(now);
Ok(())
}
}
fn origin_key(key: &RateLimitKey) -> String {
format!("{}:{}", key.route, key.origin)
}
fn prune(bucket: &mut Vec<i64>, cutoff: i64) {
bucket.retain(|timestamp| *timestamp > cutoff);
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicI64, Ordering};
struct FakeClock(AtomicI64);
impl FakeClock {
fn new(now: i64) -> Self {
Self(AtomicI64::new(now))
}
fn set(&self, now: i64) {
self.0.store(now, Ordering::SeqCst);
}
}
impl Clock for FakeClock {
fn now_millis(&self) -> i64 {
self.0.load(Ordering::SeqCst)
}
}
fn key(origin: &str) -> RateLimitKey {
RateLimitKey {
origin: origin.to_owned(),
route: "/api/pair".to_owned(),
}
}
#[tokio::test]
async fn limits_five_failures_per_origin_per_minute_with_injected_clock() {
let clock = Arc::new(FakeClock::new(1_000));
let limiter = InMemoryPairAttemptLimiter::new(Arc::clone(&clock) as Arc<dyn Clock>);
for _ in 0..PER_ORIGIN_LIMIT {
assert_eq!(
limiter.check(key("1.2.3.4")).await.unwrap(),
PairAttemptDecision::Allowed
);
limiter.record_failure(key("1.2.3.4")).await.unwrap();
}
assert_eq!(
limiter.check(key("1.2.3.4")).await.unwrap(),
PairAttemptDecision::RateLimited
);
assert_eq!(
limiter.check(key("1.2.3.5")).await.unwrap(),
PairAttemptDecision::Allowed
);
clock.set(61_001);
assert_eq!(
limiter.check(key("1.2.3.4")).await.unwrap(),
PairAttemptDecision::Allowed
);
}
#[tokio::test]
async fn limits_thirty_failures_globally_per_minute() {
let clock = Arc::new(FakeClock::new(1_000));
let limiter = InMemoryPairAttemptLimiter::new(clock as Arc<dyn Clock>);
for n in 0..GLOBAL_LIMIT {
let key = key(&format!("10.0.0.{n}"));
assert_eq!(
limiter.check(key.clone()).await.unwrap(),
PairAttemptDecision::Allowed
);
limiter.record_failure(key).await.unwrap();
}
assert_eq!(
limiter.check(key("10.0.0.99")).await.unwrap(),
PairAttemptDecision::RateLimited
);
}
}

View File

@ -0,0 +1,176 @@
//! Filesystem-backed persistent paired-device store.
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use domain::ports::{DeviceSessionStore, FileSystem, FsError, RemotePath, StoreError};
use domain::PairedDevice;
const DEVICES_DOC_VERSION: u8 = 1;
const SECURITY_DIR: &str = "security";
const DEVICES_FILE: &str = "devices.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct DevicesDoc {
version: u8,
devices: Vec<PairedDevice>,
}
/// JSON-file implementation for `{app_data_dir}/security/devices.json`.
#[derive(Clone)]
pub struct FsDeviceSessionStore {
fs: Arc<dyn FileSystem>,
app_data_dir: String,
}
impl FsDeviceSessionStore {
/// Builds the store from an injected filesystem port and app-data directory.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
Self {
fs,
app_data_dir: app_data_dir.into(),
}
}
fn security_dir(&self) -> RemotePath {
RemotePath::new(format!(
"{}/{}",
self.app_data_dir.trim_end_matches(['/', '\\']),
SECURITY_DIR
))
}
fn path(&self) -> RemotePath {
RemotePath::new(format!("{}/{}", self.security_dir().as_str(), DEVICES_FILE))
}
}
#[async_trait]
impl DeviceSessionStore for FsDeviceSessionStore {
async fn load_devices(&self) -> Result<Vec<PairedDevice>, StoreError> {
match self.fs.read(&self.path()).await {
Ok(bytes) => {
let doc: DevicesDoc = serde_json::from_slice(&bytes)
.map_err(|err| StoreError::Serialization(err.to_string()))?;
if doc.version != DEVICES_DOC_VERSION {
return Err(StoreError::Serialization(format!(
"unsupported devices.json version {}",
doc.version
)));
}
Ok(doc.devices)
}
Err(FsError::NotFound(_)) => Ok(Vec::new()),
Err(err) => Err(StoreError::Io(err.to_string())),
}
}
async fn save_devices(&self, devices: &[PairedDevice]) -> Result<(), StoreError> {
self.fs
.create_dir_all(&self.security_dir())
.await
.map_err(|err| StoreError::Io(err.to_string()))?;
let doc = DevicesDoc {
version: DEVICES_DOC_VERSION,
devices: devices.to_vec(),
};
let mut bytes = serde_json::to_vec_pretty(&doc)
.map_err(|err| StoreError::Serialization(err.to_string()))?;
bytes.push(b'\n');
self.fs
.write(&self.path(), &bytes)
.await
.map_err(|err| StoreError::Io(err.to_string()))
}
}
#[cfg(test)]
mod tests {
use self::infrastructure_test_support::TempFs;
use super::*;
use domain::{DeviceId, DeviceName, SessionTokenHash};
use uuid::Uuid;
mod infrastructure_test_support {
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::LocalFileSystem;
use domain::ports::FileSystem;
pub struct TempFs {
pub root: PathBuf,
pub fs: Arc<dyn FileSystem>,
}
impl TempFs {
pub fn new() -> Self {
let root = std::env::temp_dir()
.join(format!("idea-devices-store-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
Self {
root,
fs: Arc::new(LocalFileSystem::new()),
}
}
pub fn path(&self, rel: &str) -> PathBuf {
self.root.join(Path::new(rel))
}
}
impl Drop for TempFs {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
}
fn device() -> PairedDevice {
PairedDevice {
device_id: DeviceId::from_uuid(Uuid::from_u128(1)),
name: DeviceName::new("Phone").unwrap(),
paired_at_ms: 1000,
last_seen_at_ms: 1000,
session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]),
}
}
#[tokio::test]
async fn missing_file_loads_empty_device_list() {
let temp = TempFs::new();
let store = FsDeviceSessionStore::new(Arc::clone(&temp.fs), temp.root.to_string_lossy());
assert_eq!(store.load_devices().await.unwrap(), Vec::new());
}
#[tokio::test]
async fn devices_round_trip_in_v1_document() {
let temp = TempFs::new();
let store = FsDeviceSessionStore::new(Arc::clone(&temp.fs), temp.root.to_string_lossy());
store.save_devices(&[device()]).await.unwrap();
let loaded = store.load_devices().await.unwrap();
assert_eq!(loaded, vec![device()]);
let raw = std::fs::read_to_string(temp.path("security/devices.json")).unwrap();
assert!(raw.contains("\"version\": 1"));
assert!(raw.contains("\"deviceId\""));
assert!(raw.contains("\"sessionTokenHash\""));
}
#[tokio::test]
async fn corrupted_json_is_a_serialization_error() {
let temp = TempFs::new();
std::fs::create_dir_all(temp.path("security")).unwrap();
std::fs::write(temp.path("security/devices.json"), b"{not json").unwrap();
let store = FsDeviceSessionStore::new(Arc::clone(&temp.fs), temp.root.to_string_lossy());
let err = store.load_devices().await.unwrap_err();
assert!(matches!(err, StoreError::Serialization(_)));
}
}

View File

@ -6,6 +6,7 @@
mod background_task; mod background_task;
mod context; mod context;
mod device_session;
mod embedder; mod embedder;
mod live_state; mod live_state;
mod memory; mod memory;
@ -19,6 +20,7 @@ mod window_state;
pub use background_task::{BackgroundTaskReconcileReport, FsBackgroundTaskStore}; pub use background_task::{BackgroundTaskReconcileReport, FsBackgroundTaskStore};
pub use context::IdeaiContextStore; pub use context::IdeaiContextStore;
pub use device_session::FsDeviceSessionStore;
#[cfg(feature = "vector-onnx")] #[cfg(feature = "vector-onnx")]
pub use embedder::OnnxEmbedder; pub use embedder::OnnxEmbedder;
#[cfg(feature = "vector-http")] #[cfg(feature = "vector-http")]

View File

@ -25,8 +25,12 @@ uuid = { workspace = true }
base64 = "0.22" base64 = "0.22"
bytes = "1.11" bytes = "1.11"
cookie = "0.18" cookie = "0.18"
getrandom = { workspace = true }
hex = { workspace = true }
http = "1.4" http = "1.4"
http-body-util = "0.1" http-body-util = "0.1"
sha2 = { workspace = true }
subtle = { workspace = true }
[features] [features]
vector-http = ["backend/vector-http"] vector-http = ["backend/vector-http"]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
/**
* Tauri-backed device gateway (ticket #77, lot F2).
*
* The desktop app hosts the embedded server, so these commands reach the same
* device use cases through the composition root — **never** over HTTP to its own
* server (carnet #77, cadrage Architecture).
*
* Command names and envelopes are those B3 landed (`commands.rs`).
*/
import { invoke } from "@tauri-apps/api/core";
import type { PairedDevice, PairingCode } from "@/domain";
import type { DeviceGateway } from "@/ports";
/** Backend `DeviceListDto` — the list is wrapped, not returned bare. */
interface DeviceListDto {
devices: PairedDevice[];
}
export class TauriDeviceGateway implements DeviceGateway {
async listDevices(): Promise<PairedDevice[]> {
const dto = await invoke<DeviceListDto>("list_devices");
return dto.devices;
}
createPairingCode(): Promise<PairingCode> {
return invoke<PairingCode>("create_pairing_code");
}
renameDevice(deviceId: string, name: string): Promise<void> {
return invoke<void>("rename_device", { request: { deviceId, name } });
}
revokeDevice(deviceId: string): Promise<void> {
return invoke<void>("revoke_device", { request: { deviceId } });
}
revokeAllDevices(): Promise<void> {
return invoke<void>("revoke_all_devices");
}
}

View File

@ -0,0 +1,51 @@
/**
* HTTP device gateway (ticket #77, lot F2) — the web sibling of
* `TauriDeviceGateway`, behind the unchanged {@link DeviceGateway} port.
*
* Routes live outside the generic `/api/invoke` RPC, next to the pre-existing
* `/api/pair` and `/api/logout`: the RPC endpoint is auth-gated, so the surface
* that manages auth cannot ride on it (carnet #77).
*
* Paths are those B3 landed (`web-server/src/lib.rs`).
*/
import type { PairedDevice, PairingCode } from "@/domain";
import type { DeviceGateway } from "@/ports";
import type { HttpInvoker } from "./httpInvoker";
/** Backend shape of `GET /api/devices`: the list is wrapped, never bare. */
interface DeviceListResponse {
devices: PairedDevice[];
}
export class HttpDeviceGateway implements DeviceGateway {
constructor(private readonly http: HttpInvoker) {}
async listDevices(): Promise<PairedDevice[]> {
const body = await this.http.request<DeviceListResponse>("GET", "/api/devices");
return body.devices;
}
createPairingCode(): Promise<PairingCode> {
return this.http.request<PairingCode>("POST", "/api/pairing-code");
}
renameDevice(deviceId: string, name: string): Promise<void> {
return this.http.request<void>(
"POST",
`/api/devices/${encodeURIComponent(deviceId)}/rename`,
{ name },
);
}
revokeDevice(deviceId: string): Promise<void> {
return this.http.request<void>(
"POST",
`/api/devices/${encodeURIComponent(deviceId)}/revoke`,
);
}
revokeAllDevices(): Promise<void> {
return this.http.request<void>("POST", "/api/devices/revoke-all");
}
}

View File

@ -117,7 +117,25 @@ export class HttpInvoker {
* Tauri adapter passes (frequently `{ request: { … } }`). Resolves with the * Tauri adapter passes (frequently `{ request: { … } }`). Resolves with the
* parsed result, or rejects with a {@link GatewayError}. * parsed result, or rejects with a {@link GatewayError}.
*/ */
async invoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> { invoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
return this.request<T>("POST", "/api/invoke", { command, args }, `command '${command}'`);
}
/**
* Sends one authenticated REST request and maps the response exactly like
* {@link invoke} (401 routing + `ErrorDto` preservation).
*
* The device/auth surface of ticket #77 lives on dedicated routes rather than
* the generic RPC, alongside the pre-existing `/api/pair` and `/api/logout` —
* the RPC endpoint is itself auth-gated, so the surface that *manages* auth
* cannot ride on it. `what` names the call in fallback error messages.
*/
async request<T>(
method: string,
path: string,
body?: unknown,
what = `${method} ${path}`,
): Promise<T> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
}; };
@ -125,10 +143,10 @@ export class HttpInvoker {
let res: Awaited<ReturnType<FetchLike>>; let res: Awaited<ReturnType<FetchLike>>;
try { try {
res = await this.fetchImpl(`${this.baseUrl}/api/invoke`, { res = await this.fetchImpl(`${this.baseUrl}${path}`, {
method: "POST", method,
headers, headers,
body: JSON.stringify({ command, args }), body: body === undefined ? undefined : JSON.stringify(body),
// Same-origin so the HttpOnly session cookie is sent automatically // Same-origin so the HttpOnly session cookie is sent automatically
// (ticket #13: no secret in the URL/headers from JS). // (ticket #13: no secret in the URL/headers from JS).
credentials: "same-origin", credentials: "same-origin",
@ -136,7 +154,7 @@ export class HttpInvoker {
} catch (networkError) { } catch (networkError) {
const err: GatewayError = { const err: GatewayError = {
code: "TRANSPORT_ERROR", code: "TRANSPORT_ERROR",
message: `HTTP request for '${command}' failed: ${String(networkError)}`, message: `HTTP request for ${what} failed: ${String(networkError)}`,
}; };
throw err; throw err;
} }
@ -152,16 +170,16 @@ export class HttpInvoker {
} catch { } catch {
parsed = undefined; parsed = undefined;
} }
throw toGatewayError(parsed, `command '${command}' failed (HTTP ${res.status})`); throw toGatewayError(parsed, `${what} failed (HTTP ${res.status})`);
} }
// A 204 / empty body maps to `undefined` (the void commands). // A 204 / empty body maps to `undefined` (the void commands).
let body: unknown; let parsed: unknown;
try { try {
body = await res.json(); parsed = await res.json();
} catch { } catch {
body = undefined; parsed = undefined;
} }
return body as T; return parsed as T;
} }
} }

View File

@ -20,6 +20,7 @@ import { HttpInvoker } from "./httpInvoker";
import { WsLiveClient } from "./wsLiveClient"; import { WsLiveClient } from "./wsLiveClient";
import { getWebSession } from "./webSession"; import { getWebSession } from "./webSession";
import { setWebLiveClient } from "./webLive"; import { setWebLiveClient } from "./webLive";
import { HttpDeviceGateway } from "./deviceGateway";
import { import {
HttpConversationGateway, HttpConversationGateway,
HttpEmbedderGateway, HttpEmbedderGateway,
@ -129,6 +130,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
skill: new HttpSkillGateway(http), skill: new HttpSkillGateway(http),
memory: new HttpMemoryGateway(http), memory: new HttpMemoryGateway(http),
embedder: new HttpEmbedderGateway(http), embedder: new HttpEmbedderGateway(http),
device: new HttpDeviceGateway(http),
permission: new HttpPermissionGateway(http), permission: new HttpPermissionGateway(http),
workState: new HttpWorkStateGateway(http), workState: new HttpWorkStateGateway(http),
conversation: new HttpConversationGateway(http), conversation: new HttpConversationGateway(http),

View File

@ -1,7 +1,12 @@
/** /**
* F2 — the web session: pairing handshake (success marks the flag; wrong code * F2 — the web session: pairing handshake (success marks the flag; a failure
* rejects with a clear message), and the 401 → unauthorized transition that * rejects with a clear message), and the 401 → unauthorized transition that
* clears the flag and notifies subscribers. * clears the flag and notifies subscribers.
*
* #77 extends the handshake to `{code, name}` and freezes the two exposed
* failure codes. The "no oracle" cases below are a **security** contract, not a
* wording preference: if they start distinguishing wrong from expired from
* already-used, the API is leaking to an attacker.
*/ */
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
@ -35,35 +40,106 @@ function fetchReturning(status: number, body: unknown): {
} }
describe("WebSession pairing", () => { describe("WebSession pairing", () => {
it("POSTs the code to /api/pair and marks the flag on success", async () => { it("POSTs {code, name} to /api/pair and marks the flag on success", async () => {
const store = memStore(); const store = memStore();
const { fetchImpl, calls } = fetchReturning(200, { ok: true }); const { fetchImpl, calls } = fetchReturning(200, { ok: true });
const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store }); const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store });
expect(session.isPaired()).toBe(false); expect(session.isPaired()).toBe(false);
await session.pair("4821-93"); await session.pair("AB12CD34", "iPhone");
expect(session.isPaired()).toBe(true); expect(session.isPaired()).toBe(true);
expect(calls[0].url).toBe("https://host/api/pair"); expect(calls[0].url).toBe("https://host/api/pair");
expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({ code: "4821-93" }); expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({
code: "AB12CD34",
name: "iPhone",
});
}); });
it("rejects a wrong code with a clear message and stays unpaired", async () => { it("maps invalid_or_expired to the single frozen message", async () => {
const store = memStore(); const store = memStore();
const { fetchImpl } = fetchReturning(401, { code: "INVALID", message: "bad code" }); const { fetchImpl } = fetchReturning(401, {
code: "invalid_or_expired",
message: "whatever the server says",
});
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store }); const session = new WebSession({ baseUrl: "https://host", fetchImpl, store });
await expect(session.pair("nope")).rejects.toMatchObject({ code: "INVALID_PAIRING_CODE" }); await expect(session.pair("nope", "iPhone")).rejects.toMatchObject({
code: "invalidOrExpired",
message: "Code invalide ou expiré.",
});
expect(session.isPaired()).toBe(false); expect(session.isPaired()).toBe(false);
}); });
it("falls back to a default message when the server sends no body", async () => { it("maps invalid_name to its own actionable message", async () => {
// B3 validates the name *before* consuming the code, so this failure leaves
// the code alive — the message must not send the user regenerating one.
const { fetchImpl } = fetchReturning(400, {
code: "invalid_name",
message: "device name must be 40 characters or fewer",
});
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });
await expect(session.pair("AB12CD34", "x".repeat(60))).rejects.toMatchObject({
code: "invalidName",
message: "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.",
});
});
it("does not blame the name for an unrelated 400", async () => {
// Only the wire code may point at the name; a bare bad request must not send
// the user editing a field that was fine.
const { fetchImpl } = fetchReturning(400, undefined);
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });
await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({
code: "pairingFailed",
});
});
it("maps rate_limited to its own message", async () => {
const { fetchImpl } = fetchReturning(429, { code: "rate_limited", message: "slow down" });
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });
await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({
code: "rateLimited",
message: "Trop de tentatives. Réessayez dans quelques minutes avec un nouveau code.",
});
});
it("never forwards the server's own wording, invalid_name included", async () => {
const { fetchImpl } = fetchReturning(400, {
code: "invalid_name",
message: "device name is required",
});
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });
await expect(session.pair("AB12CD34", " ")).rejects.toMatchObject({
message: "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.",
});
});
it("never forwards a server message that could distinguish failure causes", async () => {
// Even if a future backend leaks the distinction in its message, the UI must
// not repeat it: an attacker learns nothing about *why* a code was refused.
const { fetchImpl } = fetchReturning(401, {
code: "invalid_or_expired",
message: "code already used at 14:02 by device iPhone",
});
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });
await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({
message: "Code invalide ou expiré.",
});
});
it("falls back to the invalid/expired message when the server sends no body", async () => {
const { fetchImpl } = fetchReturning(403, undefined); const { fetchImpl } = fetchReturning(403, undefined);
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() }); const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });
await expect(session.pair("x")).rejects.toMatchObject({ await expect(session.pair("x", "iPhone")).rejects.toMatchObject({
code: "INVALID_PAIRING_CODE", code: "invalidOrExpired",
message: "Code d'appairage invalide.", message: "Code invalide ou expiré.",
}); });
}); });
}); });
@ -113,7 +189,7 @@ describe("WebSession default fetch receiver", () => {
try { try {
// No `fetchImpl` injected ⇒ the global fallback is used. // No `fetchImpl` injected ⇒ the global fallback is used.
const session = new WebSession({ baseUrl: "https://host", store: memStore() }); const session = new WebSession({ baseUrl: "https://host", store: memStore() });
await session.pair("4821-93"); await session.pair("AB12CD34", "iPhone");
} finally { } finally {
globalThis.fetch = original; globalThis.fetch = original;
} }

View File

@ -65,6 +65,63 @@ function defaultBaseUrl(): string {
: "http://localhost"; : "http://localhost";
} }
/**
* The failure codes `POST /api/pair` exposes (#77).
*
* `invalidOrExpired` deliberately covers wrong, expired, superseded **and
* already-used** codes: the API gives an attacker no oracle to tell them apart,
* so neither may the UI. Do not add a case here without changing that decision.
*
* `invalidName` is *not* an exception to that rule: the name is the caller's own
* input, so rejecting it reveals nothing about the code. The server validates it
* **before** consuming the code (B3), which is what makes the distinction safe
* to surface — and what lets the user retry with the same code.
*/
export type PairingErrorCode =
| "invalidOrExpired"
| "invalidName"
| "rateLimited"
| "pairingFailed";
/** User-facing labels, frozen by UX in the #77 carnet. */
const PAIRING_ERROR_MESSAGE: Record<PairingErrorCode, string> = {
invalidOrExpired: "Code invalide ou expiré.",
// Says which field is wrong and, just as importantly, that the code survived:
// without that clause the reflex is to close the panel and burn a new code.
invalidName: "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.",
rateLimited:
"Trop de tentatives. Réessayez dans quelques minutes avec un nouveau code.",
pairingFailed: "Échec de l'appairage.",
};
/**
* Maps a `/api/pair` failure onto one of the exposed codes.
*
* The server's own `message` is **not** forwarded: it is mapped through the
* frozen labels above so a future backend wording can never reintroduce the
* wrong/expired/used distinction in the UI. The wire code wins; the HTTP status
* is the fallback for an unparseable body.
*/
function toPairingError(parsed: unknown, status: number): GatewayError {
const wire =
parsed && typeof parsed === "object" && "code" in parsed
? String((parsed as { code: unknown }).code)
: undefined;
let code: PairingErrorCode;
if (wire === "invalid_or_expired") code = "invalidOrExpired";
else if (wire === "invalid_name") code = "invalidName";
else if (wire === "rate_limited") code = "rateLimited";
else if (status === 429) code = "rateLimited";
// A bare 400 is not mapped to `invalidName`: only the wire code may claim the
// name is at fault, or an unrelated bad request would send the user editing a
// field that was fine.
else if (status === 401 || status === 403) code = "invalidOrExpired";
else code = "pairingFailed";
return { code, message: PAIRING_ERROR_MESSAGE[code] };
}
/** /**
* The web session state machine. One instance is shared between the pairing UI * The web session state machine. One instance is shared between the pairing UI
* and the HTTP invoker (via the module singleton below). * and the HTTP invoker (via the module singleton below).
@ -118,17 +175,20 @@ export class WebSession {
} }
/** /**
* Performs the pairing handshake: `POST /api/pair {code}`. On success the * Performs the pairing handshake: `POST /api/pair {code, name}` (#77). `name`
* server sets the session cookie and this records the paired flag. A wrong code * is the human label for *this* device, typed on it at pairing time. On success
* rejects with a {@link GatewayError} carrying a clear message. * the server sets the session cookie and this records the paired flag.
*
* Failures reject with a {@link GatewayError} carrying a user-facing message
* from {@link pairingErrorMessage}.
*/ */
async pair(code: string): Promise<void> { async pair(code: string, name: string): Promise<void> {
let res: Awaited<ReturnType<FetchLike>>; let res: Awaited<ReturnType<FetchLike>>;
try { try {
res = await this.fetchImpl(`${this.baseUrl}/api/pair`, { res = await this.fetchImpl(`${this.baseUrl}/api/pair`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }), body: JSON.stringify({ code, name }),
}); });
} catch (networkError) { } catch (networkError) {
const err: GatewayError = { const err: GatewayError = {
@ -145,17 +205,7 @@ export class WebSession {
} catch { } catch {
parsed = undefined; parsed = undefined;
} }
const message = throw toPairingError(parsed, res.status);
parsed && typeof parsed === "object" && "message" in parsed
? String((parsed as GatewayError).message)
: res.status === 401 || res.status === 403
? "Code d'appairage invalide."
: `Échec de l'appairage (HTTP ${res.status}).`;
const err: GatewayError = {
code: res.status === 401 || res.status === 403 ? "INVALID_PAIRING_CODE" : "PAIRING_FAILED",
message,
};
throw err;
} }
// Cookie is now set by the server (HttpOnly — not read here); record the flag. // Cookie is now set by the server (HttpOnly — not read here); record the flag.

View File

@ -25,6 +25,7 @@ import { TauriTemplateGateway } from "./template";
import { TauriSkillGateway } from "./skill"; import { TauriSkillGateway } from "./skill";
import { TauriMemoryGateway } from "./memory"; import { TauriMemoryGateway } from "./memory";
import { TauriEmbedderGateway } from "./embedder"; import { TauriEmbedderGateway } from "./embedder";
import { TauriDeviceGateway } from "./device";
import { TauriGitGateway } from "./git"; import { TauriGitGateway } from "./git";
import { TauriPermissionGateway } from "./permission"; import { TauriPermissionGateway } from "./permission";
import { TauriWorkStateGateway } from "./workState"; import { TauriWorkStateGateway } from "./workState";
@ -66,6 +67,7 @@ export function createTauriGateways(): Gateways {
skill: new TauriSkillGateway(), skill: new TauriSkillGateway(),
memory: new TauriMemoryGateway(), memory: new TauriMemoryGateway(),
embedder: new TauriEmbedderGateway(), embedder: new TauriEmbedderGateway(),
device: new TauriDeviceGateway(),
permission: new TauriPermissionGateway(), permission: new TauriPermissionGateway(),
workState: new TauriWorkStateGateway(), workState: new TauriWorkStateGateway(),
conversation: new TauriConversationGateway(), conversation: new TauriConversationGateway(),
@ -90,6 +92,7 @@ export {
TauriSkillGateway, TauriSkillGateway,
TauriMemoryGateway, TauriMemoryGateway,
TauriEmbedderGateway, TauriEmbedderGateway,
TauriDeviceGateway,
TauriGitGateway, TauriGitGateway,
TauriPermissionGateway, TauriPermissionGateway,
TauriWorkStateGateway, TauriWorkStateGateway,

View File

@ -32,6 +32,8 @@ import type {
MemoryLink, MemoryLink,
MemoryType, MemoryType,
EffectivePermissions, EffectivePermissions,
PairedDevice,
PairingCode,
PermissionSet, PermissionSet,
Project, Project,
ProjectPermissions, ProjectPermissions,
@ -68,6 +70,7 @@ import type {
CreateMemoryInput, CreateMemoryInput,
CreateSkillInput, CreateSkillInput,
DesktopServerGateway, DesktopServerGateway,
DeviceGateway,
EmbedderGateway, EmbedderGateway,
CreateTemplateInput, CreateTemplateInput,
Gateways, Gateways,
@ -1471,8 +1474,6 @@ export class MockDesktopServerGateway implements DesktopServerGateway {
? undefined ? undefined
: this.settings.publicOrigin, : this.settings.publicOrigin,
upstreamUrl: preview.upstreamUrl, upstreamUrl: preview.upstreamUrl,
// Runtime-only, regenerated per start — never persisted.
pairingCode: "MOCK-PAIR-4242",
}); });
return structuredClone(this.current); return structuredClone(this.current);
} }
@ -2040,6 +2041,66 @@ export class MockInputGateway implements InputGateway {
} }
} }
/**
* In-memory paired-device gateway (ticket #77) — stands in for B1/B2/B3 while
* the backend lands, and drives the `VITE_USE_MOCK` dev surface.
*
* Models the parts of the frozen contract the UI can actually observe: a device
* list with exactly one `isCurrentDevice`, single-use codes with a 600 s TTL
* where generating invalidates the previous one, and revocation. It does **not**
* model the wire (no HTTP, no cookie): that is the HTTP gateway's job.
*/
export class MockDeviceGateway implements DeviceGateway {
private devices: PairedDevice[] = [];
private issued = 0;
/** Seeds the device list (tests/dev). */
_setDevices(devices: PairedDevice[]): void {
this.devices = structuredClone(devices);
}
/** Every code handed out so far, newest last — the previous ones are dead. */
_issuedCodes: string[] = [];
async listDevices(): Promise<PairedDevice[]> {
return structuredClone(this.devices);
}
async createPairingCode(): Promise<PairingCode> {
// Deterministic 8-char uppercase hex, same shape as the server's.
const code = (0x10000000 + this.issued++)
.toString(16)
.toUpperCase()
.slice(0, 8);
this._issuedCodes.push(code);
const ttlSeconds = 600;
return {
code,
// Epoch ms, exactly as the server sends it — a mock that invents a
// friendlier encoding is how the ISO/epoch mismatch stayed green (#77).
expiresAtMs: Date.now() + ttlSeconds * 1000,
ttlSeconds,
};
}
async renameDevice(deviceId: string, name: string): Promise<void> {
const device = this.devices.find((d) => d.deviceId === deviceId);
if (!device) {
const err: GatewayError = { code: "NOT_FOUND", message: `unknown device ${deviceId}` };
throw err;
}
device.name = name;
}
async revokeDevice(deviceId: string): Promise<void> {
this.devices = this.devices.filter((d) => d.deviceId !== deviceId);
}
async revokeAllDevices(): Promise<void> {
this.devices = [];
}
}
/** In-memory permissions gateway. */ /** In-memory permissions gateway. */
export class MockPermissionGateway implements PermissionGateway { export class MockPermissionGateway implements PermissionGateway {
private docs = new Map<string, ProjectPermissions>(); private docs = new Map<string, ProjectPermissions>();
@ -2782,6 +2843,7 @@ export function createMockGateways(): Gateways {
skill: new MockSkillGateway(agentGateway), skill: new MockSkillGateway(agentGateway),
memory: new MockMemoryGateway(), memory: new MockMemoryGateway(),
embedder: new MockEmbedderGateway(), embedder: new MockEmbedderGateway(),
device: new MockDeviceGateway(),
permission: new MockPermissionGateway(), permission: new MockPermissionGateway(),
workState: new MockWorkStateGateway(), workState: new MockWorkStateGateway(),
conversation: new MockConversationGateway(), conversation: new MockConversationGateway(),

View File

@ -17,6 +17,7 @@ describe("createMockGateways", () => {
"agent", "agent",
"conversation", "conversation",
"desktopServer", "desktopServer",
"device",
"embedder", "embedder",
"focusedProject", "focusedProject",
"git", "git",

View File

@ -190,9 +190,11 @@ export type EmbeddedServerState =
| "failed"; | "failed";
/** /**
* Embedded-server status (mirror of `EmbeddedServerStatusDto`). `pairingCode` * Embedded-server status (mirror of `EmbeddedServerStatusDto`).
* is a runtime-only secret: it exists while the server runs, is never *
* persisted, and must never be written into a settings field. * Carries **no pairing code** (#77): a code no longer exists while the server
* runs, only when someone asks for one. Generating is a device-management action
* ({@link PairingCode}), not a property of the server's status.
*/ */
export interface EmbeddedServerStatus { export interface EmbeddedServerStatus {
state: EmbeddedServerState; state: EmbeddedServerState;
@ -202,8 +204,6 @@ export interface EmbeddedServerStatus {
publicUrl?: string; publicUrl?: string;
/** Upstream URL to hand to the reverse proxy. */ /** Upstream URL to hand to the reverse proxy. */
upstreamUrl?: string; upstreamUrl?: string;
/** Runtime pairing code — present only while running. */
pairingCode?: string;
/** Last failure, when `state` is `failed`. */ /** Last failure, when `state` is `failed`. */
error?: GatewayError; error?: GatewayError;
} }
@ -1321,3 +1321,52 @@ export type ReplyChunk =
| { kind: "toolActivity"; label: string } | { kind: "toolActivity"; label: string }
| { kind: "final"; content: string } | { kind: "final"; content: string }
| { kind: "error"; message: string }; | { kind: "error"; message: string };
// ---------------------------------------------------------------------------
// Paired devices + pairing code (ticket #77)
// ---------------------------------------------------------------------------
/**
* One device paired with this IdeA instance (mirror of the backend device DTO).
*
* Deliberately carries **no IP and no User-Agent**: the design is mono-user and
* the list is an access-management surface, not a forensics log. `name` is the
* human label typed on the device itself at pairing time.
*/
export interface PairedDevice {
deviceId: string;
name: string;
/** Epoch milliseconds the device was paired. */
pairedAtMs: number;
/** Epoch milliseconds of the device's last authenticated request. */
lastSeenAtMs: number;
/** True for the device rendering this list (never true on desktop). */
isCurrentDevice: boolean;
}
/**
* A freshly generated, single-use pairing code (mirror of the backend DTO).
*
* `code` is the **canonical** value — uppercase hex, no separator. Any grouping
* shown to the user is presentation only; see {@link normalizePairingCode}.
*/
export interface PairingCode {
code: string;
/** Epoch milliseconds the code stops being accepted. */
expiresAtMs: number;
/** Lifetime granted at generation (600 s today). */
ttlSeconds: number;
}
/**
* Canonical form of a pairing code as the server compares it: uppercase, with
* spaces **and dashes** removed.
*
* Both separators matter. The UI groups the code visually (`AB12 CD34`) and a
* user may retype it with a dash out of habit, so a code copied by eye must
* still pair. The server normalises too (#76) — this keeps the client honest
* rather than being the only line of defence.
*/
export function normalizePairingCode(raw: string): string {
return raw.replace(/[\s-]+/g, "").toUpperCase();
}

View File

@ -0,0 +1,80 @@
/**
* A small modal confirmation, local to the devices feature (ticket #77, lot F2).
*
* Not `FloatingWindow`: that is a draggable desktop window, and this surface is
* mobile-first. Not a shared primitive either — adding a dialog to the kit is a
* design-system decision for UX/Architect, not a side effect of this ticket.
*/
import { useEffect, useRef } from "react";
import { Button, zIndex } from "@/shared";
interface ConfirmDialogProps {
title: string;
body: string;
confirmLabel: string;
/** Paints the confirm action as destructive (revoke-all). */
danger?: boolean;
busy?: boolean;
onConfirm: () => void | Promise<void>;
onCancel: () => void;
}
export function ConfirmDialog({
title,
body,
confirmLabel,
danger = false,
busy = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const cancelRef = useRef<HTMLButtonElement>(null);
// Mount-only: a confirmation opens focused on the safe choice. Depending on
// `onCancel` here would re-steal focus whenever the parent re-renders (#17).
useEffect(() => {
cancelRef.current?.focus();
}, []);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onCancel();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onCancel]);
return (
<div
className="fixed inset-0 flex items-center justify-center bg-black/50 p-4"
style={{ zIndex: zIndex.floatingWindow }}
onClick={onCancel}
>
<div
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-sm flex-col gap-3 rounded-lg border border-border bg-raised p-4 shadow-xl"
>
<h3 className="text-sm font-semibold text-content">{title}</h3>
<p className="text-sm text-muted">{body}</p>
<div className="flex justify-end gap-2">
<Button ref={cancelRef} size="sm" variant="ghost" onClick={onCancel}>
Annuler
</Button>
<Button
size="sm"
variant={danger ? "danger" : "primary"}
loading={busy}
onClick={() => void onConfirm()}
>
{confirmLabel}
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,291 @@
/**
* #77 lot F2 — the shared devices surface, driven through the mock gateway
* (B1/B2/B3 are not landed; the mock models the frozen DTO contract).
*
* The load-bearing assertions:
* - the copied code carries **no separator**, whatever the display shows;
* - no IP / User-Agent ever reaches the DOM;
* - revoking the current device ends the session instead of refreshing a list
* we are no longer allowed to read.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { Gateways } from "@/ports";
import type { PairedDevice } from "@/domain";
import { DIProvider } from "@/app/di";
import { createMockGateways, MockDeviceGateway } from "@/adapters/mock";
import { DevicesScreen } from "./DevicesScreen";
/**
* Epoch-ms fixtures — the encoding the backend actually sends (`*AtMs: number`).
* These were ISO strings, which let the suite pass while the real screen
* rendered "—" on every row.
*/
const DEVICES: PairedDevice[] = [
{
deviceId: "d1",
name: "iPhone",
pairedAtMs: new Date(2026, 6, 12, 10, 0).getTime(),
lastSeenAtMs: Date.now(),
isCurrentDevice: true,
},
{
deviceId: "d2",
name: "Chrome sur Windows",
pairedAtMs: new Date(2026, 5, 1, 10, 0).getTime(),
lastSeenAtMs: new Date(2026, 6, 12, 14, 32).getTime(),
isCurrentDevice: false,
},
];
function setup(devices: PairedDevice[] = DEVICES) {
const gateways: Gateways = createMockGateways();
const device = gateways.device as MockDeviceGateway;
device._setDevices(devices);
const onSessionEnded = vi.fn();
render(
<DIProvider gateways={gateways}>
<DevicesScreen onSessionEnded={onSessionEnded} />
</DIProvider>,
);
return { device, onSessionEnded };
}
const writeText = vi.fn(async (_text: string) => {});
beforeEach(() => {
writeText.mockClear();
Object.defineProperty(window.navigator, "clipboard", {
value: { writeText },
configurable: true,
});
});
const rowFor = async (name: string) =>
(await screen.findByText(name)).closest("li") as HTMLElement;
describe("DevicesScreen list", () => {
it("lists devices with their name, activity and pairing date", async () => {
setup();
const row = await rowFor("Chrome sur Windows");
expect(within(row).getByText("Appairé le 1 juin")).toBeTruthy();
expect(screen.getAllByTestId("device-row")).toHaveLength(2);
});
it("badges the current device", async () => {
setup();
const current = await rowFor("iPhone");
expect(within(current).getByText("Cet appareil")).toBeTruthy();
const other = await rowFor("Chrome sur Windows");
expect(within(other).queryByText("Cet appareil")).toBeNull();
});
it("never renders an IP or a User-Agent", async () => {
setup();
await screen.findByTestId("device-list");
const text = screen.getByTestId("devices-screen").textContent ?? "";
expect(text).not.toMatch(/Mozilla|AppleWebKit|\d+\.\d+\.\d+\.\d+/);
});
it("says so plainly when nothing is paired", async () => {
setup([]);
expect(await screen.findByText("Aucun appareil appairé.")).toBeTruthy();
});
});
describe("DevicesScreen pairing code", () => {
it("copies the canonical code, with no separator in the clipboard value", async () => {
setup();
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
const panel = await screen.findByTestId("pairing-code-panel");
fireEvent.click(within(panel).getByRole("button", { name: "Copier" }));
await waitFor(() => expect(writeText).toHaveBeenCalled());
const copied = writeText.mock.calls[0][0];
// The whole point of the arbitration: a dash here would be retyped by the
// user and refused by the server (#75).
expect(copied).toMatch(/^[0-9A-F]{8}$/);
expect(copied).not.toContain("-");
expect(copied).not.toContain(" ");
});
it("groups the code visually while keeping the value intact for readers", async () => {
setup();
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
const value = await screen.findByTestId("pairing-code-value");
// Two blocks of 4 on screen…
const blocks = value.querySelectorAll("span");
expect(blocks).toHaveLength(2);
expect(blocks[0].textContent).toHaveLength(4);
expect(blocks[1].textContent).toHaveLength(4);
// …but a screen reader hears the code it must type, unseparated.
expect(value.getAttribute("aria-label")).toMatch(/^[0-9A-F]{8}$/);
});
it("shows the instruction and a countdown", async () => {
setup();
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
expect(await screen.findByText("Saisissez ce code sur le nouvel appareil.")).toBeTruthy();
expect((await screen.findByTestId("pairing-code-countdown")).textContent).toBe(
"Expire dans 10 min",
);
});
it("does not declare a freshly generated code expired", async () => {
// The visible symptom of the ISO/epoch mismatch (#77): `new Date(<epoch-ms>)`
// was Invalid Date ⇒ the countdown read null ⇒ every brand-new code showed
// "Ce code a expiré." the instant it appeared. Guard the whole failure, not
// just the formatter.
setup();
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
await screen.findByTestId("pairing-code-panel");
expect(screen.queryByText("Ce code a expiré.")).toBeNull();
expect(screen.queryByRole("button", { name: "Générer un nouveau code" })).toBeNull();
expect(screen.getByTestId("pairing-code-countdown").textContent).toMatch(
/^Expire dans \d+ (min|s)$/,
);
});
it("replaces the code rather than stacking panels when regenerating", async () => {
const { device } = setup();
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
await screen.findByTestId("pairing-code-panel");
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
await waitFor(() => expect(device._issuedCodes).toHaveLength(2));
expect(screen.getAllByTestId("pairing-code-panel")).toHaveLength(1);
// Generating invalidates the previous code server-side; the UI shows only
// the live one so nobody reads a dead code aloud.
const shown = (await screen.findByTestId("pairing-code-value")).getAttribute("aria-label");
expect(shown).toBe(device._issuedCodes[1]);
});
it("announces expiry without alarmism and offers a new code", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
setup();
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
await screen.findByTestId("pairing-code-panel");
await vi.advanceTimersByTimeAsync(601_000);
expect(await screen.findByText("Ce code a expiré.")).toBeTruthy();
expect(screen.getByRole("button", { name: "Générer un nouveau code" })).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
});
describe("DevicesScreen rename", () => {
it("renames through the ⋯ menu", async () => {
const { device } = setup();
const row = await rowFor("Chrome sur Windows");
fireEvent.click(within(row).getByRole("button", { name: "Actions pour Chrome sur Windows" }));
fireEvent.click(within(row).getByRole("menuitem", { name: "Renommer" }));
fireEvent.change(screen.getByDisplayValue("Chrome sur Windows"), {
target: { value: "PC du bureau" },
});
fireEvent.click(screen.getByRole("button", { name: "Renommer" }));
expect(await screen.findByText("PC du bureau")).toBeTruthy();
expect((await device.listDevices()).find((d) => d.deviceId === "d2")?.name).toBe(
"PC du bureau",
);
});
});
describe("DevicesScreen revocation", () => {
async function openRevoke(name: string) {
const row = await rowFor(name);
fireEvent.click(within(row).getByRole("button", { name: `Actions pour ${name}` }));
fireEvent.click(within(row).getByRole("menuitem", { name: "Révoquer" }));
return screen.findByRole("dialog");
}
it("confirms before revoking another device", async () => {
const { device, onSessionEnded } = setup();
const dialog = await openRevoke("Chrome sur Windows");
expect(within(dialog).getByText("Révoquer cet appareil ?")).toBeTruthy();
expect(
within(dialog).getByText("Il devra être appairé à nouveau pour accéder à IdeA."),
).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "Révoquer" }));
await waitFor(() => expect(screen.queryByText("Chrome sur Windows")).toBeNull());
expect(await device.listDevices()).toHaveLength(1);
// Revoking someone else must not touch our own session.
expect(onSessionEnded).not.toHaveBeenCalled();
});
it("cancelling leaves the device alone", async () => {
const { device } = setup();
const dialog = await openRevoke("Chrome sur Windows");
fireEvent.click(within(dialog).getByRole("button", { name: "Annuler" }));
expect(screen.getByText("Chrome sur Windows")).toBeTruthy();
expect(await device.listDevices()).toHaveLength(2);
});
it("warns about immediate sign-out when revoking the current device", async () => {
setup();
const dialog = await openRevoke("iPhone");
expect(within(dialog).getByText(/Vous serez déconnecté immédiatement\./)).toBeTruthy();
});
it("ends the session when the current device revokes itself (nominal case)", async () => {
const { onSessionEnded } = setup();
const dialog = await openRevoke("iPhone");
fireEvent.click(within(dialog).getByRole("button", { name: "Révoquer" }));
await waitFor(() => expect(onSessionEnded).toHaveBeenCalled());
});
it("revokes everything, current device included, behind a strong confirmation", async () => {
const { device, onSessionEnded } = setup();
await screen.findByTestId("device-list");
fireEvent.click(screen.getByRole("button", { name: "Révoquer tous les appareils" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("Révoquer tous les appareils ?")).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "Tout révoquer" }));
await waitFor(() => expect(onSessionEnded).toHaveBeenCalled());
expect(await device.listDevices()).toHaveLength(0);
});
it("does not end the session when revoke-all excludes the current device", async () => {
// Desktop: no device is ever `isCurrentDevice`, so wiping the list must not
// pretend the app just logged itself out.
const { onSessionEnded } = setup(DEVICES.map((d) => ({ ...d, isCurrentDevice: false })));
await screen.findByTestId("device-list");
fireEvent.click(screen.getByRole("button", { name: "Révoquer tous les appareils" }));
fireEvent.click(
within(await screen.findByRole("dialog")).getByRole("button", { name: "Tout révoquer" }),
);
await waitFor(() => expect(screen.getByText("Aucun appareil appairé.")).toBeTruthy());
expect(onSessionEnded).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,317 @@
/**
* `DevicesScreen` — the paired-device surface (ticket #77, lot F2).
*
* Mounted **identically** in the web UI and in the desktop app
* (`Paramètres → Appareils`): same component, same vocabulary, same actions. It
* is mobile-first by necessity, not by taste — a headless install has only the
* web UI to generate a code from, and that is often reached from a phone.
*
* It shows a name, a badge, an activity phrase and a pairing date. It never
* shows an IP or a User-Agent: this is an access-management surface for a
* single-user instance, not an audit log.
*
* Transport-neutral (gateways via DI). The session consequence of revoking the
* current device is *not* decided here: {@link onSessionEnded} hands it to the
* mounting surface, which is the only layer that knows what "logged out" means.
*/
import { useEffect, useState } from "react";
import type { PairedDevice } from "@/domain";
import { Button, Field, Input, Panel, Spinner, cn } from "@/shared";
import { useDevices } from "./useDevices";
import { formatLastSeen, formatPairedAt } from "./formatActivity";
import { PairingCodePanel } from "./PairingCodePanel";
import { ConfirmDialog } from "./ConfirmDialog";
import { DEVICE_NAME_MAX } from "@/features/web/deviceName";
interface DevicesScreenProps {
/**
* Called when this device's own session has just been revoked (directly or by
* "revoke all"). Web routes back to pairing; desktop leaves it unset — the
* desktop app hosts the server and is never itself a paired device.
*/
onSessionEnded?: () => void;
}
export function DevicesScreen({ onSessionEnded }: DevicesScreenProps) {
const vm = useDevices();
const [confirmRevoke, setConfirmRevoke] = useState<PairedDevice | null>(null);
const [confirmRevokeAll, setConfirmRevokeAll] = useState(false);
const [renaming, setRenaming] = useState<string | null>(null);
useEffect(() => {
if (vm.sessionEnded) onSessionEnded?.();
}, [vm.sessionEnded, onSessionEnded]);
const devices = vm.devices;
return (
<section data-testid="devices-screen" className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-col gap-0.5">
<h2 className="text-base font-semibold tracking-tight">Appareils</h2>
<p className="text-sm text-muted">
Les appareils autorisés à accéder à cette instance IdeA.
</p>
</div>
<Button onClick={() => void vm.generateCode()} loading={vm.generating}>
Appairer
</Button>
</div>
{vm.code && (
<PairingCodePanel
code={vm.code}
onRegenerate={() => void vm.generateCode()}
onDismiss={vm.dismissCode}
/>
)}
{vm.error && (
<Panel className="border-danger/40">
<p role="alert" className="text-sm text-danger">
{vm.error}
</p>
</Panel>
)}
{devices === null ? (
<span className="inline-flex items-center gap-1.5 text-sm text-muted">
<Spinner size={12} /> Chargement des appareils
</span>
) : devices.length === 0 ? (
<p className="text-sm text-muted">Aucun appareil appairé.</p>
) : (
<ul className="flex flex-col divide-y divide-border" data-testid="device-list">
{devices.map((device) => (
<DeviceRow
key={device.deviceId}
device={device}
fresh={vm.freshDeviceIds.includes(device.deviceId)}
renaming={renaming === device.deviceId}
onStartRename={() => setRenaming(device.deviceId)}
onCancelRename={() => setRenaming(null)}
onRename={async (name) => {
await vm.rename(device.deviceId, name);
setRenaming(null);
}}
onRevoke={() => setConfirmRevoke(device)}
/>
))}
</ul>
)}
{devices !== null && devices.length > 0 && (
<div className="pt-1">
<Button variant="ghost" size="sm" onClick={() => setConfirmRevokeAll(true)}>
Révoquer tous les appareils
</Button>
</div>
)}
{confirmRevoke && (
<ConfirmDialog
title="Révoquer cet appareil ?"
body={
confirmRevoke.isCurrentDevice
? "Il devra être appairé à nouveau pour accéder à IdeA. Vous serez déconnecté immédiatement."
: "Il devra être appairé à nouveau pour accéder à IdeA."
}
confirmLabel="Révoquer"
busy={vm.busy}
onConfirm={async () => {
const target = confirmRevoke;
setConfirmRevoke(null);
await vm.revoke(target);
}}
onCancel={() => setConfirmRevoke(null)}
/>
)}
{confirmRevokeAll && (
<ConfirmDialog
title="Révoquer tous les appareils ?"
// Stated without needing the list: the point of this action is the lost
// phone you cannot enumerate calmly.
body="Tous les appareils, y compris celui-ci, devront être appairés à nouveau. Vous serez déconnecté immédiatement."
confirmLabel="Tout révoquer"
danger
busy={vm.busy}
onConfirm={async () => {
setConfirmRevokeAll(false);
await vm.revokeAll();
}}
onCancel={() => setConfirmRevokeAll(false)}
/>
)}
</section>
);
}
/** One device: identity, activity, and the `⋯` actions. */
function DeviceRow({
device,
fresh,
renaming,
onStartRename,
onCancelRename,
onRename,
onRevoke,
}: {
device: PairedDevice;
fresh: boolean;
renaming: boolean;
onStartRename: () => void;
onCancelRename: () => void;
onRename: (name: string) => Promise<void>;
onRevoke: () => void;
}) {
return (
<li
data-testid="device-row"
data-fresh={fresh || undefined}
className={cn(
"py-3 transition-colors first:pt-0 last:pb-0",
fresh && "bg-success/10",
)}
>
{renaming ? (
<RenameForm device={device} onSubmit={onRename} onCancel={onCancelRename} />
) : (
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-content">{device.name}</span>
{device.isCurrentDevice && (
<span className="shrink-0 rounded-full bg-primary/15 px-2 py-0.5 text-[0.65rem] font-medium text-primary">
Cet appareil
</span>
)}
</span>
<span className="text-xs text-muted">{formatLastSeen(device.lastSeenAtMs)}</span>
<span className="text-[0.7rem] text-faint">{formatPairedAt(device.pairedAtMs)}</span>
</div>
<RowMenu deviceName={device.name} onRename={onStartRename} onRevoke={onRevoke} />
</div>
)}
</li>
);
}
/** Inline rename (1-40 characters), submitted with Enter or the button. */
function RenameForm({
device,
onSubmit,
onCancel,
}: {
device: PairedDevice;
onSubmit: (name: string) => Promise<void>;
onCancel: () => void;
}) {
const [name, setName] = useState(device.name);
const trimmed = name.trim();
return (
<form
className="flex flex-col gap-2"
onSubmit={(e) => {
e.preventDefault();
if (trimmed.length > 0) void onSubmit(trimmed);
}}
>
<Field label="Nom de l'appareil" hideLabel>
{({ id }) => (
<Input
id={id}
value={name}
autoFocus
maxLength={DEVICE_NAME_MAX}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
/>
)}
</Field>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={trimmed.length === 0}>
Renommer
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>
Annuler
</Button>
</div>
</form>
);
}
/**
* The per-row `⋯` menu. Local to this feature rather than a shared primitive:
* the kit has no dropdown yet, and inventing one is a design-system decision
* (UX/Architect), not a side effect of this ticket.
*/
function RowMenu({
deviceName,
onRename,
onRevoke,
}: {
deviceName: string;
onRename: () => void;
onRevoke: () => void;
}) {
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return;
const close = () => setOpen(false);
// Any click outside dismisses; capture so a click on another row's trigger
// still opens that one (its handler runs after this closes us).
window.addEventListener("click", close);
return () => window.removeEventListener("click", close);
}, [open]);
return (
<div className="relative shrink-0" onClick={(e) => e.stopPropagation()}>
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-label={`Actions pour ${deviceName}`}
onClick={() => setOpen((v) => !v)}
className="rounded-md px-2 py-1 text-sm text-muted transition-colors hover:bg-raised hover:text-content"
>
</button>
{open && (
<div
role="menu"
className="absolute right-0 top-full z-10 mt-1 flex min-w-36 flex-col rounded-md border border-border bg-raised py-1 shadow-lg"
>
<button
type="button"
role="menuitem"
onClick={() => {
setOpen(false);
onRename();
}}
className="px-3 py-1.5 text-left text-sm text-content transition-colors hover:bg-canvas"
>
Renommer
</button>
<button
type="button"
role="menuitem"
onClick={() => {
setOpen(false);
onRevoke();
}}
className="px-3 py-1.5 text-left text-sm text-danger transition-colors hover:bg-canvas"
>
Révoquer
</button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,118 @@
/**
* The generated pairing code panel (ticket #77, lot F2).
*
* **Grouping is presentation only.** The code is rendered as two blocks of four
* for readability, but the blocks are separate elements with CSS spacing — there
* is no separator character anywhere in the value, and `Copier` puts the exact
* canonical code (`AB12CD34`) on the clipboard. A dash rendered here would be
* retyped by the user and would reintroduce the very failure #75 just fixed
* (arbitrage Main, carnet #77).
*
* The countdown is honest about a code that is already dead: at expiry the panel
* switches to the expired state rather than letting someone type a code the
* server will refuse.
*/
import { useEffect, useState } from "react";
import type { PairingCode } from "@/domain";
import { Button, Panel } from "@/shared";
import { formatExpiresIn } from "./formatActivity";
interface PairingCodePanelProps {
code: PairingCode;
onRegenerate: () => void;
onDismiss: () => void;
}
/** Splits the code into fixed blocks of 4 for display. Never mutates the value. */
export function codeBlocks(code: string, size = 4): string[] {
const blocks: string[] = [];
for (let i = 0; i < code.length; i += size) blocks.push(code.slice(i, i + size));
return blocks;
}
/** Copies text, preferring the async clipboard API and degrading silently. */
async function copyToClipboard(text: string): Promise<boolean> {
try {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// Denied permission / insecure context: fall through to the failure notice.
}
return false;
}
export function PairingCodePanel({ code, onRegenerate, onDismiss }: PairingCodePanelProps) {
const [remaining, setRemaining] = useState(() => formatExpiresIn(code.expiresAtMs));
const [copied, setCopied] = useState(false);
useEffect(() => {
setRemaining(formatExpiresIn(code.expiresAtMs));
const timer = setInterval(() => setRemaining(formatExpiresIn(code.expiresAtMs)), 1000);
return () => clearInterval(timer);
}, [code.expiresAtMs]);
useEffect(() => {
setCopied(false);
}, [code.code]);
useEffect(() => {
if (!copied) return;
const timer = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(timer);
}, [copied]);
const expired = remaining === null;
return (
<Panel data-testid="pairing-code-panel" className="flex flex-col gap-3">
{expired ? (
<>
<p className="text-sm text-muted">Ce code a expiré.</p>
<div>
<Button size="sm" onClick={onRegenerate}>
Générer un nouveau code
</Button>
</div>
</>
) : (
<>
<p className="text-sm text-muted">Saisissez ce code sur le nouvel appareil.</p>
<p
data-testid="pairing-code-value"
// The accessible name is the plain code: a screen reader must hear
// the value to type, not the visual blocks.
aria-label={code.code}
className="flex gap-3 font-mono text-2xl font-semibold tracking-[0.2em] text-content sm:text-3xl"
>
{codeBlocks(code.code).map((block, i) => (
<span key={i} aria-hidden="true">
{block}
</span>
))}
</p>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={() =>
void copyToClipboard(code.code).then((ok) => setCopied(ok))
}
>
{copied ? "Copié" : "Copier"}
</Button>
<span className="text-xs text-faint" data-testid="pairing-code-countdown">
{remaining}
</span>
<Button size="sm" variant="ghost" className="ml-auto" onClick={onDismiss}>
Fermer
</Button>
</div>
</>
)}
</Panel>
);
}

View File

@ -0,0 +1,77 @@
/**
* #77 lot F2 — the activity phrasing. Boundaries are calendar-based, so the
* interesting cases are the ones a 24-hour-span implementation gets wrong.
*
* Fixtures are **epoch milliseconds**, the encoding the backend actually sends.
* They used to be ISO strings, which is precisely why the whole surface passed
* its tests while rendering "—" against a real server.
*/
import { describe, it, expect } from "vitest";
import { formatExpiresIn, formatLastSeen, formatPairedAt } from "./formatActivity";
/** 2026-07-17 at 09:00 local. */
const NOW = new Date(2026, 6, 17, 9, 0, 0);
const at = (y: number, m: number, d: number, h = 0, min = 0, s = 0) =>
new Date(y, m, d, h, min, s).getTime();
describe("formatLastSeen", () => {
it("reads as 'now' within the last couple of minutes", () => {
expect(formatLastSeen(at(2026, 6, 17, 8, 59), NOW)).toBe("Actif à l'instant");
});
it("shows today's time once it is no longer 'now'", () => {
expect(formatLastSeen(at(2026, 6, 17, 6, 32), NOW)).toBe("Aujourd'hui à 06:32");
});
it("says 'Hier' for the previous calendar day, however few hours ago", () => {
// 23:59 yesterday is 9 hours back: a 24h-window implementation would call
// this "today", which reads as a lie next to the clock.
expect(formatLastSeen(at(2026, 6, 16, 23, 59), NOW)).toBe("Hier");
expect(formatLastSeen(at(2026, 6, 16, 0, 1), NOW)).toBe("Hier");
});
it("falls back to a short date beyond yesterday", () => {
expect(formatLastSeen(at(2026, 6, 12, 14, 0), NOW)).toBe("12 juil.");
});
it("adds the year once it is not the current one", () => {
expect(formatLastSeen(at(2025, 11, 3, 14, 0), NOW)).toBe("3 déc. 2025");
});
it("treats a slightly-ahead server clock as 'now', not as the future", () => {
expect(formatLastSeen(at(2026, 6, 17, 9, 0, 30), NOW)).toBe("Actif à l'instant");
});
it("reads a raw epoch-ms number, the way the backend sends it", () => {
// The regression that started this: a `1784286814274`-shaped value must
// render a real date, not the "—" an ISO-only parser produced.
const ms = new Date(2026, 6, 12, 14, 0).getTime();
expect(Number.isInteger(ms)).toBe(true);
expect(formatLastSeen(ms, NOW)).toBe("12 juil.");
});
});
describe("formatPairedAt", () => {
it("reads as a sentence, not a timestamp", () => {
expect(formatPairedAt(at(2026, 6, 12), NOW)).toBe("Appairé le 12 juil.");
});
});
describe("formatExpiresIn", () => {
const inSeconds = (s: number) => NOW.getTime() + s * 1000;
it("counts down in minutes over the code's lifetime", () => {
expect(formatExpiresIn(inSeconds(600), NOW)).toBe("Expire dans 10 min");
expect(formatExpiresIn(inSeconds(61), NOW)).toBe("Expire dans 2 min");
});
it("switches to seconds in the last minute", () => {
expect(formatExpiresIn(inSeconds(30), NOW)).toBe("Expire dans 30 s");
});
it("returns null once expired, so the panel can say so", () => {
expect(formatExpiresIn(inSeconds(0), NOW)).toBeNull();
expect(formatExpiresIn(inSeconds(-5), NOW)).toBeNull();
});
});

View File

@ -0,0 +1,70 @@
/**
* Human phrasing of device timestamps (ticket #77, lot F2).
*
* The list answers "is this thing still being used?", not "when exactly?", so
* recent activity degrades from a phrase to a time to a date as it ages. Pure
* and `now`-injectable so the boundaries are testable without faking the clock.
*
* Instants are **epoch milliseconds** (`*AtMs`, `number`) — the codebase-wide
* convention, aligned with the backend store. The format is unambiguous, so
* these functions parse nothing and tolerate no alternative encoding.
*/
/** Below this, activity reads as "now" rather than a timestamp. */
const JUST_NOW_MS = 2 * 60 * 1000;
function startOfDay(d: Date): number {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
}
/** `14:32` in 24-hour form. */
function timeOfDay(d: Date): string {
return d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
}
/** `12 juil.`, with the year appended once it is no longer the current one. */
export function shortDate(ms: number, now: Date = new Date()): string {
const d = new Date(ms);
const sameYear = d.getFullYear() === now.getFullYear();
return d.toLocaleDateString("fr-FR", {
day: "numeric",
month: "short",
...(sameYear ? {} : { year: "numeric" }),
});
}
/**
* Last-activity label: `Actif à l'instant`, `Aujourd'hui à 14:32`, `Hier`, then
* a short date. Days are compared as calendar days, not 24-hour spans — 23:59
* yesterday reads "Hier", not "Aujourd'hui".
*/
export function formatLastSeen(ms: number, now: Date = new Date()): string {
const delta = now.getTime() - ms;
// A clock skew that puts the server slightly ahead reads as "now", not as a
// date in the future.
if (delta < JUST_NOW_MS) return "Actif à l'instant";
const today = startOfDay(now);
const day = startOfDay(new Date(ms));
if (day === today) return `Aujourd'hui à ${timeOfDay(new Date(ms))}`;
if (day === today - 86_400_000) return "Hier";
return shortDate(ms, now);
}
/** Secondary line: `Appairé le 12 juil.` */
export function formatPairedAt(ms: number, now: Date = new Date()): string {
return `Appairé le ${shortDate(ms, now)}`;
}
/**
* Remaining lifetime of a pairing code: `Expire dans 10 min`, then seconds in
* the last minute so the panel stays honest as it runs out. `null` once expired
* — the caller shows the expiry state instead.
*/
export function formatExpiresIn(expiresAtMs: number, now: Date = new Date()): string | null {
const remainingMs = expiresAtMs - now.getTime();
if (remainingMs <= 0) return null;
const seconds = Math.ceil(remainingMs / 1000);
if (seconds < 60) return `Expire dans ${seconds} s`;
return `Expire dans ${Math.ceil(seconds / 60)} min`;
}

View File

@ -0,0 +1,14 @@
/**
* Paired-device management (ticket #77, lot F2) — one surface, mounted in both
* the web UI and the desktop app under `Paramètres → Appareils`.
*/
export { DevicesScreen } from "./DevicesScreen";
export { useDevices } from "./useDevices";
export type { UseDevices } from "./useDevices";
export {
formatLastSeen,
formatPairedAt,
formatExpiresIn,
shortDate,
} from "./formatActivity";

View File

@ -0,0 +1,186 @@
/**
* Device-management state (ticket #77, lot F2).
*
* Owns the list, the ephemeral pairing code and the revoke/rename actions, so
* {@link DevicesScreen} stays a rendering concern. Transport-neutral: every call
* goes through the injected {@link DeviceGateway}, which is the Tauri adapter on
* desktop and the HTTP one on web.
*
* Revoking the **current** device ends this session, so the screen must not try
* to refresh afterwards — the hook reports it through `sessionEnded` and the
* mounting surface decides what that means (web: back to pairing; desktop: never
* happens, no device is ever `isCurrentDevice`).
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { GatewayError, PairedDevice, PairingCode } from "@/domain";
import { useGateways } from "@/app/di";
/** How often the list is re-read while a code is live, to catch a new pairing. */
const PAIRING_POLL_MS = 3000;
/** How long a freshly-paired row stays highlighted. */
const HIGHLIGHT_MS = 2500;
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
export interface UseDevices {
devices: PairedDevice[] | null;
error: string | null;
/** An action is in flight; drives the confirm dialogs' pending state. */
busy: boolean;
/** Narrower than `busy`: only a code generation, so `Appairer` alone spins. */
generating: boolean;
/** The live code, or `null` when no code has been generated (or it was dismissed). */
code: PairingCode | null;
/** Device ids to highlight — the ones that appeared while a code was live. */
freshDeviceIds: string[];
/** Set once the current device's own session has been revoked. */
sessionEnded: boolean;
refresh(): Promise<void>;
generateCode(): Promise<void>;
dismissCode(): void;
rename(deviceId: string, name: string): Promise<void>;
revoke(device: PairedDevice): Promise<void>;
revokeAll(): Promise<void>;
}
export function useDevices(): UseDevices {
const { device: gateway } = useGateways();
const [devices, setDevices] = useState<PairedDevice[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [generating, setGenerating] = useState(false);
const [code, setCode] = useState<PairingCode | null>(null);
const [freshDeviceIds, setFreshDeviceIds] = useState<string[]>([]);
const [sessionEnded, setSessionEnded] = useState(false);
// Read inside the poll callback without making it a dependency (which would
// restart the interval on every list change).
const knownIds = useRef<Set<string> | null>(null);
const load = useCallback(async (): Promise<PairedDevice[] | null> => {
try {
const list = await gateway.listDevices();
setDevices(list);
setError(null);
return list;
} catch (e) {
setError(describe(e));
return null;
}
}, [gateway]);
const refresh = useCallback(async () => {
const list = await load();
if (list) knownIds.current = new Set(list.map((d) => d.deviceId));
}, [load]);
useEffect(() => {
void refresh();
}, [refresh]);
// While a code is live, a new device may pair at any moment and the backend
// pushes no event for it (B3 scope), so poll. The interval exists only for the
// few minutes the code is valid, never in steady state.
useEffect(() => {
if (!code) return;
const timer = setInterval(() => {
void (async () => {
const list = await load();
if (!list) return;
const known = knownIds.current;
knownIds.current = new Set(list.map((d) => d.deviceId));
if (!known) return;
const fresh = list.filter((d) => !known.has(d.deviceId)).map((d) => d.deviceId);
if (fresh.length > 0) setFreshDeviceIds(fresh);
})();
}, PAIRING_POLL_MS);
return () => clearInterval(timer);
}, [code, load]);
// The highlight is an acknowledgement, not a state: let it fade on its own.
useEffect(() => {
if (freshDeviceIds.length === 0) return;
const timer = setTimeout(() => setFreshDeviceIds([]), HIGHLIGHT_MS);
return () => clearTimeout(timer);
}, [freshDeviceIds]);
const run = useCallback(
async (action: () => Promise<void>): Promise<boolean> => {
setBusy(true);
setError(null);
try {
await action();
return true;
} catch (e) {
setError(describe(e));
return false;
} finally {
setBusy(false);
}
},
[],
);
const generateCode = useCallback(async () => {
setGenerating(true);
try {
await run(async () => {
// Generating invalidates the previous code server-side; mirror that by
// replacing it here rather than stacking panels.
setCode(await gateway.createPairingCode());
});
} finally {
setGenerating(false);
}
}, [gateway, run]);
const dismissCode = useCallback(() => setCode(null), []);
const rename = useCallback(
async (deviceId: string, name: string) => {
if (await run(() => gateway.renameDevice(deviceId, name))) await refresh();
},
[gateway, run, refresh],
);
const revoke = useCallback(
async (target: PairedDevice) => {
// Read `isCurrentDevice` *before* the call: afterwards the session may be
// gone and the list unreadable.
const wasCurrent = target.isCurrentDevice;
if (!(await run(() => gateway.revokeDevice(target.deviceId)))) return;
if (wasCurrent) setSessionEnded(true);
else await refresh();
},
[gateway, run, refresh],
);
const revokeAll = useCallback(async () => {
const includedCurrent = devices?.some((d) => d.isCurrentDevice) ?? false;
if (!(await run(() => gateway.revokeAllDevices()))) return;
if (includedCurrent) setSessionEnded(true);
else await refresh();
}, [devices, gateway, run, refresh]);
return {
devices,
error,
busy,
generating,
code,
freshDeviceIds,
sessionEnded,
refresh,
generateCode,
dismissCode,
rename,
revoke,
revokeAll,
};
}

View File

@ -5,7 +5,7 @@
* These pin the UX invariants the screen exists for, not its styling: the mode * These pin the UX invariants the screen exists for, not its styling: the mode
* is a radio choice with consequences, the authorized-proxy field is explicitly * is a radio choice with consequences, the authorized-proxy field is explicitly
* *not* a listen address, addresses come from the backend, a refusal is * *not* a listen address, addresses come from the backend, a refusal is
* actionable, and the pairing code is runtime-only. * actionable, and no pairing code is ever shown here (#77).
*/ */
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
@ -142,30 +142,31 @@ describe("DeploymentSettings", () => {
expect(screen.queryByRole("textbox", { name: /upstream/i })).toBeNull(); expect(screen.queryByRole("textbox", { name: /upstream/i })).toBeNull();
}); });
it("keeps the pairing code runtime-only: absent until running, gone after stop", async () => { it("never shows a pairing code, running or not (#77)", async () => {
// A code is no longer a property of a running server: it exists only when
// asked for. Starting the server must not make one appear here — this is
// what the old "runtime-only code" test asserted, and it can no longer be
// true of any backend response.
renderView(); renderView();
await settle(); await settle();
expect(
screen.getByText("Start the server to generate a pairing code."),
).toBeTruthy();
expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull(); expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Start" })); fireEvent.click(screen.getByRole("button", { name: "Start" }));
await waitFor(() => expect(screen.getByText("Running")).toBeTruthy());
expect( expect(screen.queryByRole("button", { name: "copy pairing code" })).toBeNull();
await screen.findByRole("button", { name: "copy pairing code" }), expect(screen.queryByText(/generate a pairing code/i)).toBeNull();
).toBeTruthy(); });
expect(
screen.getByText(/Temporary code. It disappears when the server stops/),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Stop" })); it("points to where pairing now lives instead of dead-ending", async () => {
await waitFor(() => // Someone who just started the server from this screen needs to know where
expect( // to go next; silence would be a cul-de-sac.
screen.getByText("Start the server to generate a pairing code."), renderView();
).toBeTruthy(), await settle();
);
const pairing = screen.getByText(/Pairing is managed in/);
expect(within(pairing).getByText("Settings → Appareils")).toBeTruthy();
}); });
it("starts the server and reports the local URL", async () => { it("starts the server and reports the local URL", async () => {

View File

@ -16,8 +16,11 @@
* the whole reason this screen is worded the way it is; the help text under * the whole reason this screen is worded the way it is; the help text under
* the field says so explicitly. * the field says so explicitly.
* *
* The pairing code is runtime-only: shown while running, never rendered into a * This screen no longer shows a pairing code (#77). A code is not a property of
* persisted field, never mixed with the upstream value. * a running server — it exists only when someone asks for one — so it lives in
* the Appareils surface next to the devices it authorises. What is left here is
* a signpost: starting the server from this screen and finding no way to pair is
* a dead end.
*/ */
import { useState } from "react"; import { useState } from "react";
@ -334,21 +337,12 @@ export function DeploymentSettings() {
</Panel> </Panel>
)} )}
{/* ── Pairing: runtime-only secret, isolated from the upstream ──────── */} {/* ── Pairing moved to its own surface (#77) — leave a signpost ─────── */}
<Panel title="Pairing"> <Panel title="Pairing">
{running && status.pairingCode ? ( <p className="text-sm text-muted">
<div className="flex flex-col gap-2"> Pairing is managed in <span className="text-content">Settings Appareils</span>,
<ReadOnlyValue value={status.pairingCode} copyLabel="copy pairing code" /> where you can generate a code and revoke devices.
<p className="text-xs text-muted"> </p>
Temporary code. It disappears when the server stops. Do not save it
in configuration files.
</p>
</div>
) : (
<p className="text-sm text-muted">
Start the server to generate a pairing code.
</p>
)}
</Panel> </Panel>
</div> </div>
); );

View File

@ -17,19 +17,32 @@
import { Button, cn } from "@/shared"; import { Button, cn } from "@/shared";
import { ProfilesSettings } from "@/features/first-run"; import { ProfilesSettings } from "@/features/first-run";
import { DevicesScreen } from "@/features/devices";
import { DeploymentSettings } from "./DeploymentSettings"; import { DeploymentSettings } from "./DeploymentSettings";
/** The Settings sections, in menu/nav order. */ /** The Settings sections, in menu/nav order. */
export type SettingsSection = "aiProfiles" | "deployment"; export type SettingsSection = "aiProfiles" | "deployment" | "devices";
/** Human labels, shared by the nav column and the `Settings` menu. */ /**
* Human labels, shared by the nav column and the `Settings` menu.
*
* `Appareils` is French where its neighbours are English: the device vocabulary
* is frozen by UX across web and desktop (carnet #77), and the web surface it
* mirrors is French throughout. Aligning the whole Settings surface on one
* language is a UX call beyond this ticket.
*/
export const SETTINGS_SECTION_LABEL: Record<SettingsSection, string> = { export const SETTINGS_SECTION_LABEL: Record<SettingsSection, string> = {
aiProfiles: "AI Profiles", aiProfiles: "AI Profiles",
deployment: "Deployment", deployment: "Deployment",
devices: "Appareils",
}; };
/** Section order — the single source of truth for both nav and menu. */ /** Section order — the single source of truth for both nav and menu. */
export const SETTINGS_SECTIONS: SettingsSection[] = ["aiProfiles", "deployment"]; export const SETTINGS_SECTIONS: SettingsSection[] = [
"aiProfiles",
"deployment",
"devices",
];
interface SettingsViewProps { interface SettingsViewProps {
section: SettingsSection; section: SettingsSection;
@ -77,7 +90,15 @@ export function SettingsView({
<div className="flex flex-1 justify-center overflow-y-auto p-6"> <div className="flex flex-1 justify-center overflow-y-auto p-6">
<div className="w-full max-w-2xl"> <div className="w-full max-w-2xl">
{section === "aiProfiles" ? <ProfilesSettings /> : <DeploymentSettings />} {section === "aiProfiles" ? (
<ProfilesSettings />
) : section === "deployment" ? (
<DeploymentSettings />
) : (
// No `onSessionEnded`: the desktop app hosts the server and is never
// itself a paired device, so it cannot revoke its own session.
<DevicesScreen />
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,8 +3,13 @@
* - the field must summon the text keyboard, not the numeric keypad; * - the field must summon the text keyboard, not the numeric keypad;
* - a lowercase entry must reach `session.pair()` uppercased and space-free, * - a lowercase entry must reach `session.pair()` uppercased and space-free,
* because the server compares the code strictly. * because the server compares the code strictly.
*
* #77 lot F1 — the handshake becomes `{code, name}`:
* - dashes normalise away too, so a code retyped as `AB12-CD34` still pairs;
* - the device name is prefilled from a readable derivation, editable, and
* required; it is never a raw User-Agent.
*/ */
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { WebSession } from "@/adapters/http"; import { WebSession } from "@/adapters/http";
@ -28,50 +33,156 @@ const okFetch: FetchLike = async () => ({
text: async () => "{}", text: async () => "{}",
}); });
function setup() { /** A `/api/pair` that always fails with the given wire code. */
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); function failingFetch(status: number, code: string): FetchLike {
return async () => ({
ok: false,
status,
json: async () => ({ code, message: "server wording" }),
text: async () => "{}",
});
}
/** Pins the UA so the name prefill is deterministic. */
function stubUserAgent(ua: string): void {
Object.defineProperty(window.navigator, "userAgent", {
value: ua,
configurable: true,
});
}
const ORIGINAL_UA = window.navigator.userAgent;
function setup(fetchImpl: FetchLike = okFetch) {
const session = new WebSession({ baseUrl: "https://h", fetchImpl, store: memStore() });
const pair = vi.spyOn(session, "pair"); const pair = vi.spyOn(session, "pair");
const onPaired = vi.fn(); const onPaired = vi.fn();
render(<PairingScreen session={session} onPaired={onPaired} />); render(<PairingScreen session={session} onPaired={onPaired} />);
return { pair, onPaired }; return { pair, onPaired };
} }
const codeField = () => screen.getByLabelText("Code d'appairage");
const nameField = () => screen.getByLabelText("Nom de cet appareil") as HTMLInputElement;
const submit = () => screen.getByRole("button", { name: "Appairer" }) as HTMLButtonElement;
beforeEach(() => stubUserAgent("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"));
afterEach(() => stubUserAgent(ORIGINAL_UA));
describe("PairingScreen code field", () => { describe("PairingScreen code field", () => {
it("asks for the text keyboard, not the numeric keypad", () => { it("asks for the text keyboard, not the numeric keypad", () => {
setup(); setup();
const field = screen.getByLabelText("Code d'appairage");
expect(field.getAttribute("inputmode")).toBe("text"); expect(codeField().getAttribute("inputmode")).toBe("text");
expect(field.getAttribute("autocapitalize")).toBe("characters"); expect(codeField().getAttribute("autocapitalize")).toBe("characters");
expect(field.getAttribute("autocomplete")).toBe("one-time-code"); expect(codeField().getAttribute("autocomplete")).toBe("one-time-code");
}); });
it("describes the code without lying about its shape", () => { it("describes the code without lying about its shape", () => {
setup(); setup();
const field = screen.getByLabelText("Code d'appairage");
expect(field.getAttribute("placeholder")).toBe("p. ex. AB12CD34"); expect(codeField().getAttribute("placeholder")).toBe("p. ex. AB12CD34");
expect(screen.getByText("8 caractères : chiffres et lettres A-F")).toBeTruthy(); expect(screen.getByText("8 caractères : chiffres et lettres A-F")).toBeTruthy();
}); });
it("uppercases and strips spaces before pairing", async () => { it("uppercases and strips spaces before pairing", async () => {
const { pair, onPaired } = setup(); const { pair, onPaired } = setup();
fireEvent.change(screen.getByLabelText("Code d'appairage"), { fireEvent.change(codeField(), { target: { value: " ab12cd34 " } });
target: { value: " ab12cd34 " }, fireEvent.click(submit());
});
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
await vi.waitFor(() => expect(onPaired).toHaveBeenCalled()); await vi.waitFor(() => expect(onPaired).toHaveBeenCalled());
expect(pair).toHaveBeenCalledWith("AB12CD34"); expect(pair).toHaveBeenCalledWith("AB12CD34", "iPhone");
}); });
it("keeps submit disabled for a blank code", () => { it("strips a dash the user retyped from the grouped display (#77)", async () => {
setup(); const { pair, onPaired } = setup();
const submit = screen.getByRole("button", { name: "Appairer" });
expect((submit as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Code d'appairage"), { target: { value: " " } }); fireEvent.change(codeField(), { target: { value: "AB12-CD34" } });
expect((submit as HTMLButtonElement).disabled).toBe(true); fireEvent.click(submit());
await vi.waitFor(() => expect(onPaired).toHaveBeenCalled());
expect(pair).toHaveBeenCalledWith("AB12CD34", "iPhone");
});
});
describe("PairingScreen device name", () => {
it("prefills a readable name, never the raw User-Agent", () => {
setup();
expect(nameField().value).toBe("iPhone");
expect(document.body.textContent).not.toContain("Mozilla/5.0");
});
it("sends the edited name with the code", async () => {
const { pair, onPaired } = setup();
fireEvent.change(codeField(), { target: { value: "AB12CD34" } });
fireEvent.change(nameField(), { target: { value: " Téléphone de Marie " } });
fireEvent.click(submit());
await vi.waitFor(() => expect(onPaired).toHaveBeenCalled());
expect(pair).toHaveBeenCalledWith("AB12CD34", "Téléphone de Marie");
});
it("caps the name at 40 characters", () => {
setup();
expect(nameField().maxLength).toBe(40);
});
it("requires both a code and a name", () => {
setup();
// A name alone (prefilled) is not enough.
expect(submit().disabled).toBe(true);
fireEvent.change(codeField(), { target: { value: "AB12CD34" } });
expect(submit().disabled).toBe(false);
// Clearing the name blocks it again: the list must never show a blank row.
fireEvent.change(nameField(), { target: { value: " " } });
expect(submit().disabled).toBe(true);
});
it("keeps submit disabled for a blank or separator-only code", () => {
setup();
fireEvent.change(codeField(), { target: { value: " " } });
expect(submit().disabled).toBe(true);
fireEvent.change(codeField(), { target: { value: " - " } });
expect(submit().disabled).toBe(true);
});
});
describe("PairingScreen error placement", () => {
async function submitAndFail(status: number, code: string) {
setup(failingFetch(status, code));
fireEvent.change(codeField(), { target: { value: "AB12CD34" } });
fireEvent.click(submit());
await screen.findByRole("alert");
}
it("shows a rejected name on the name field, not as a form error", async () => {
await submitAndFail(400, "invalid_name");
// Attached to the field: the fix is one edit away, and the code is still
// valid — a form-level error would read as "start over".
const message = screen.getByRole("alert");
expect(message.textContent).toBe(
"Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.",
);
expect(nameField().getAttribute("aria-describedby")).toBe(message.id);
expect(nameField().getAttribute("aria-invalid")).toBe("true");
expect(screen.queryByTestId("pairing-error")).toBeNull();
// The code was never consumed, so it must not be painted as the culprit.
expect(codeField().getAttribute("aria-invalid")).not.toBe("true");
});
it("keeps a rejected code as a form error", async () => {
await submitAndFail(401, "invalid_or_expired");
expect(screen.getByTestId("pairing-error").textContent).toBe("Code invalide ou expiré.");
// The name is not at fault, so it must not be marked as such.
expect(nameField().getAttribute("aria-invalid")).not.toBe("true");
}); });
}); });

View File

@ -1,10 +1,15 @@
/** /**
* Web pairing screen — ticket #13, lot F2. * Web pairing screen — ticket #13 lot F2, extended by #75 and #77 lot F1.
* *
* Shown by {@link WebApp} when the client is not paired. The user types the code * Shown by {@link WebApp} when the client is not paired. The user types the code
* the server printed at first launch; on submit we `POST /api/pair {code}` via * generated from an already-paired device (or printed by `--new-code`) plus a
* the {@link WebSession}. On success the server sets the HttpOnly session cookie * name for *this* device; on submit we `POST /api/pair {code, name}` via the
* and we advance to the workspace; a wrong code shows a clear message. * {@link WebSession}. On success the server sets the HttpOnly session cookie and
* we advance to the workspace.
*
* The device name is typed here, on the new device, because this is the only
* moment the person is holding it — the generating device cannot know what to
* call it. It is prefilled from a readable derivation (never a raw User-Agent).
* *
* Transport-neutral at the component seam: it talks to the injected * Transport-neutral at the component seam: it talks to the injected
* {@link WebSession}, never to `@tauri-apps/api` (the CI guard `no-direct-invoke` * {@link WebSession}, never to `@tauri-apps/api` (the CI guard `no-direct-invoke`
@ -13,9 +18,10 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import type { GatewayError } from "@/domain"; import { normalizePairingCode, type GatewayError } from "@/domain";
import { Button, Field, Input, Panel } from "@/shared"; import { Button, Field, Input, Panel } from "@/shared";
import type { WebSession } from "@/adapters/http"; import type { WebSession } from "@/adapters/http";
import { clampDeviceName, currentDeviceName, DEVICE_NAME_MAX } from "./deviceName";
interface PairingScreenProps { interface PairingScreenProps {
/** The shared web session performing the `POST /api/pair` handshake. */ /** The shared web session performing the `POST /api/pair` handshake. */
@ -24,34 +30,43 @@ interface PairingScreenProps {
onPaired: () => void; onPaired: () => void;
} }
function describe(e: unknown): string { /** The failure as the form needs it: a message plus which field to blame. */
if (e && typeof e === "object" && "message" in e) { interface PairingFailure {
return String((e as GatewayError).message); code: string;
} message: string;
return String(e);
} }
/** function describe(e: unknown): PairingFailure {
* The server compares the code strictly against the uppercase hex it generated, if (e && typeof e === "object" && "message" in e) {
* so the UI uppercases (and drops stray spaces) before sending. const err = e as GatewayError;
*/ return { code: String(err.code ?? "ERROR"), message: String(err.message) };
function normalize(raw: string): string { }
return raw.replace(/\s+/g, "").toUpperCase(); return { code: "ERROR", message: String(e) };
} }
export function PairingScreen({ session, onPaired }: PairingScreenProps) { export function PairingScreen({ session, onPaired }: PairingScreenProps) {
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null); const [name, setName] = useState(() => clampDeviceName(currentDeviceName()));
const [error, setError] = useState<PairingFailure | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const normalizedCode = normalizePairingCode(code);
const trimmedName = name.trim();
const canSubmit = normalizedCode.length > 0 && trimmedName.length > 0;
// A rejected name is the one failure the user fixes in the form rather than by
// fetching a new code (the server validates it before consuming the code, so
// the code is still live). Point at the field instead of the whole form.
const nameError = error?.code === "invalidName" ? error.message : null;
const formError = error && !nameError ? error.message : null;
async function submit(e: FormEvent): Promise<void> { async function submit(e: FormEvent): Promise<void> {
e.preventDefault(); e.preventDefault();
const normalized = normalize(code); if (!canSubmit || busy) return;
if (!normalized || busy) return;
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
await session.pair(normalized); await session.pair(normalizedCode, clampDeviceName(trimmedName));
onPaired(); onPaired();
} catch (err) { } catch (err) {
setError(describe(err)); setError(describe(err));
@ -89,18 +104,39 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) {
autoCorrect="off" autoCorrect="off"
spellCheck={false} spellCheck={false}
disabled={busy} disabled={busy}
invalid={!!error} invalid={!!formError}
/> />
)} )}
</Field> </Field>
{error && ( <Field
label="Nom de cet appareil"
hint="Pour le reconnaître dans la liste des appareils."
error={nameError ?? undefined}
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={DEVICE_NAME_MAX}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
disabled={busy}
invalid={!!nameError}
/>
)}
</Field>
{formError && (
<p role="alert" data-testid="pairing-error" className="text-sm text-danger"> <p role="alert" data-testid="pairing-error" className="text-sm text-danger">
{error} {formError}
</p> </p>
)} )}
<Button type="submit" disabled={busy || normalize(code).length === 0}> <Button type="submit" disabled={busy || !canSubmit}>
{busy ? "Appairage…" : "Appairer"} {busy ? "Appairage…" : "Appairer"}
</Button> </Button>
</form> </form>

View File

@ -8,12 +8,18 @@
* cookie) drops back to pairing automatically. "Se déconnecter" (F6) revokes the * cookie) drops back to pairing automatically. "Se déconnecter" (F6) revokes the
* server session (`POST /api/logout`), tears down the live WS singleton, and * server session (`POST /api/logout`), tears down the live WS singleton, and
* returns to pairing. * returns to pairing.
*
* "Appareils" (#77) mounts the shared {@link DevicesScreen} — the same component
* the desktop shows under `Paramètres → Appareils`. It is what makes a headless
* install usable: generating a pairing code from an already-paired phone instead
* of restarting the server with `--new-code`.
*/ */
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Button } from "@/shared"; import { Button } from "@/shared";
import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http"; import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http";
import { DevicesScreen } from "@/features/devices";
import { PairingScreen } from "./PairingScreen"; import { PairingScreen } from "./PairingScreen";
import { WebWorkspace } from "./WebWorkspace"; import { WebWorkspace } from "./WebWorkspace";
@ -26,11 +32,17 @@ export function WebApp({ session }: WebAppProps = {}) {
const webSession = session ?? getWebSession(); const webSession = session ?? getWebSession();
const [paired, setPaired] = useState(() => webSession.isPaired()); const [paired, setPaired] = useState(() => webSession.isPaired());
const [signingOut, setSigningOut] = useState(false); const [signingOut, setSigningOut] = useState(false);
const [showDevices, setShowDevices] = useState(false);
useEffect(() => { useEffect(() => {
// A 401 anywhere clears the flag and fires this: return to pairing. The live // A 401 anywhere clears the flag and fires this: return to pairing. The live
// WS is torn down by the composition-root 401 handler (F6). // WS is torn down by the composition-root 401 handler (F6). This is also the
return webSession.onUnauthorized(() => setPaired(false)); // path for a *suffered* revocation (#77): another device revoked us, the
// server rejects the next call, and we land back on pairing.
return webSession.onUnauthorized(() => {
setShowDevices(false);
setPaired(false);
});
}, [webSession]); }, [webSession]);
async function signOut(): Promise<void> { async function signOut(): Promise<void> {
@ -43,10 +55,24 @@ export function WebApp({ session }: WebAppProps = {}) {
} finally { } finally {
disconnectWebLive(); disconnectWebLive();
setSigningOut(false); setSigningOut(false);
setShowDevices(false);
setPaired(false); setPaired(false);
} }
} }
/**
* This device just revoked itself (#77) — a nominal action, not an edge case.
* The cookie is already dead server-side; drop the local flag and the live
* socket so nothing keeps retrying, and land on pairing immediately rather
* than waiting for the next request to 401.
*/
const onSessionEnded = useCallback(() => {
webSession.forget();
disconnectWebLive();
setShowDevices(false);
setPaired(false);
}, [webSession]);
return ( return (
<div className="flex h-full flex-col bg-canvas text-content"> <div className="flex h-full flex-col bg-canvas text-content">
<header <header
@ -60,17 +86,31 @@ export function WebApp({ session }: WebAppProps = {}) {
</span> </span>
</div> </div>
{paired && ( {paired && (
<Button variant="ghost" size="sm" loading={signingOut} onClick={() => void signOut()}> <div className="flex items-center gap-1">
Se déconnecter <Button
</Button> variant="ghost"
size="sm"
aria-pressed={showDevices}
onClick={() => setShowDevices((v) => !v)}
>
{showDevices ? "Projets" : "Appareils"}
</Button>
<Button variant="ghost" size="sm" loading={signingOut} onClick={() => void signOut()}>
Se déconnecter
</Button>
</div>
)} )}
</header> </header>
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{paired ? ( {!paired ? (
<WebWorkspace />
) : (
<PairingScreen session={webSession} onPaired={() => setPaired(true)} /> <PairingScreen session={webSession} onPaired={() => setPaired(true)} />
) : showDevices ? (
<div className="h-full overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))] pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:p-6">
<DevicesScreen onSessionEnded={onSessionEnded} />
</div>
) : (
<WebWorkspace />
)} )}
</div> </div>
</div> </div>

View File

@ -0,0 +1,63 @@
/**
* #77 lot F1 — the device-name prefill. The contract that matters is the
* negative one: whatever the UA says, the derived name is something a human
* would have typed, and never a fragment of the User-Agent itself.
*/
import { describe, it, expect } from "vitest";
import { clampDeviceName, deriveDeviceName } from "./deviceName";
const UA = {
iphone: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
ipad: "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
androidChrome:
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
chromeWindows:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
firefoxLinux: "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0",
safariMac:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
edgeWindows:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0",
};
describe("deriveDeviceName", () => {
it("names phones and tablets by the device, not the browser", () => {
expect(deriveDeviceName(UA.iphone)).toBe("iPhone");
expect(deriveDeviceName(UA.ipad)).toBe("iPad");
expect(deriveDeviceName(UA.androidChrome)).toBe("Android");
});
it("names desktops by browser and OS", () => {
expect(deriveDeviceName(UA.chromeWindows)).toBe("Chrome sur Windows");
expect(deriveDeviceName(UA.firefoxLinux)).toBe("Firefox sur Linux");
expect(deriveDeviceName(UA.safariMac)).toBe("Safari sur Mac");
});
it("prefers the more specific browser when a UA claims several", () => {
// Edge's UA also contains "Chrome/" and "Safari/"; naming it "Chrome" would
// make two different browsers on one machine indistinguishable in the list.
expect(deriveDeviceName(UA.edgeWindows)).toBe("Edge sur Windows");
});
it("falls back to a usable label rather than leaking the UA", () => {
expect(deriveDeviceName("")).toBe("Cet appareil");
expect(deriveDeviceName("SomeBot/1.0 (compatible)")).toBe("Cet appareil");
});
it("never returns a raw User-Agent fragment", () => {
for (const ua of Object.values(UA)) {
const name = deriveDeviceName(ua);
expect(name).not.toContain("Mozilla");
expect(name).not.toContain("/");
expect(name.length).toBeLessThanOrEqual(40);
}
});
});
describe("clampDeviceName", () => {
it("trims and caps at 40 characters", () => {
expect(clampDeviceName(" iPhone ")).toBe("iPhone");
expect(clampDeviceName("x".repeat(60))).toHaveLength(40);
});
});

View File

@ -0,0 +1,77 @@
/**
* Readable device-name derivation for the pairing screen (ticket #77, lot F1).
*
* The name is only a **prefill**: the user sees it, can rewrite it, and it is
* what the device list will show forever after. So it must read like something a
* human would type — `iPhone`, `Chrome sur Windows` — never a raw User-Agent.
* The UA string is an implementation detail of this derivation and must not
* reach any surface: no tooltip, no placeholder, no fallback text.
*
* Pure and UA-string-driven (no `navigator` access) so the mapping is testable
* without a browser; {@link currentDeviceName} is the thin impure wrapper.
*/
/** Bounds enforced by the pairing form and the rename action. */
export const DEVICE_NAME_MAX = 40;
export const DEVICE_NAME_MIN = 1;
/** Last-resort label when nothing recognisable is in the UA. */
const FALLBACK = "Cet appareil";
/** Browsers, most specific first: Edge/Opera also claim "Chrome"/"Safari". */
const BROWSERS: ReadonlyArray<[RegExp, string]> = [
[/\bEdg(?:e|A|iOS)?\//, "Edge"],
[/\bOPR\/|\bOpera\//, "Opera"],
[/\bFirefox\/|\bFxiOS\//, "Firefox"],
[/\bChrome\/|\bCriOS\//, "Chrome"],
[/\bSafari\//, "Safari"],
];
/** Desktop OSes only — phones/tablets are named by the device, not the OS. */
const DESKTOP_OS: ReadonlyArray<[RegExp, string]> = [
[/\bWindows\b/, "Windows"],
[/\bMac OS X\b|\bMacintosh\b/, "Mac"],
[/\bCrOS\b/, "ChromeOS"],
[/\bLinux\b|\bX11\b/, "Linux"],
];
/**
* Derives a human label from a User-Agent string.
*
* Phones and tablets are named by the device alone (`iPhone`, `Android`) because
* that is how people refer to them; on desktop the browser is the distinguishing
* fact when several browsers share one machine, hence `Chrome sur Windows`.
*/
export function deriveDeviceName(userAgent: string): string {
const ua = userAgent ?? "";
if (/\biPhone\b/.test(ua)) return "iPhone";
if (/\biPad\b/.test(ua)) return "iPad";
// iPadOS 13+ masquerades as a Mac; the touch points give it away, but that is
// a `navigator` fact, not a UA one — a plain "Mac" here is an honest miss.
if (/\bAndroid\b/.test(ua)) return "Android";
const browser = BROWSERS.find(([re]) => re.test(ua))?.[1];
const os = DESKTOP_OS.find(([re]) => re.test(ua))?.[1];
if (browser && os) return `${browser} sur ${os}`;
return browser ?? os ?? FALLBACK;
}
/**
* Clamps a derived or typed name to the contract's 1-40 characters.
*
* Counts **code points**, not UTF-16 units, to match the server's
* `DeviceName::new` (`chars().count()`). `slice(0, 40)` would both disagree with
* that bound on astral characters and be able to cut a surrogate pair in half,
* putting a lone surrogate on the wire.
*/
export function clampDeviceName(name: string): string {
return Array.from(name.trim()).slice(0, DEVICE_NAME_MAX).join("");
}
/** Derives the current browser's device name; `Cet appareil` outside a browser. */
export function currentDeviceName(): string {
if (typeof navigator === "undefined" || !navigator.userAgent) return FALLBACK;
return deriveDeviceName(navigator.userAgent);
}

View File

@ -34,6 +34,8 @@ import type {
MemoryType, MemoryType,
OpenCodeConfig, OpenCodeConfig,
EffectivePermissions, EffectivePermissions,
PairedDevice,
PairingCode,
PermissionSet, PermissionSet,
PageDirection, PageDirection,
Project, Project,
@ -750,6 +752,31 @@ export interface EmbedderGateway {
describeEmbedderEngines(): Promise<EmbedderEngines>; describeEmbedderEngines(): Promise<EmbedderEngines>;
} }
/**
* Paired-device management (ticket #77) — the access surface of the instance.
*
* Mounted identically on web and desktop, so it is a **port**, not a web
* concern: the desktop adapter reaches the same use cases through the Tauri
* composition root, the web adapter over HTTP. Revoking is authoritative
* server-side; revoking the *current* device ends this session, which callers
* detect from {@link PairedDevice.isCurrentDevice} before the call.
*/
export interface DeviceGateway {
/** Lists every paired device, most recently active first (backend order). */
listDevices(): Promise<PairedDevice[]>;
/**
* Generates a fresh single-use pairing code, invalidating any previous one.
* The code exists only in server memory until it is consumed or expires.
*/
createPairingCode(): Promise<PairingCode>;
/** Renames one device (1-40 characters). */
renameDevice(deviceId: string, name: string): Promise<void>;
/** Revokes one device; its live WebSockets are closed server-side (B3). */
revokeDevice(deviceId: string): Promise<void>;
/** Revokes every device, the current one included. */
revokeAllDevices(): Promise<void>;
}
/** Project and agent permission management (LP1). */ /** Project and agent permission management (LP1). */
export interface PermissionGateway { export interface PermissionGateway {
/** Reads the full project permission document. */ /** Reads the full project permission document. */
@ -1130,6 +1157,7 @@ export interface Gateways {
skill: SkillGateway; skill: SkillGateway;
memory: MemoryGateway; memory: MemoryGateway;
embedder: EmbedderGateway; embedder: EmbedderGateway;
device: DeviceGateway;
permission: PermissionGateway; permission: PermissionGateway;
workState: WorkStateGateway; workState: WorkStateGateway;
conversation: ConversationGateway; conversation: ConversationGateway;