41 Commits

Author SHA1 Message Date
17d6baf15a 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>
2026-07-17 13:27:17 +02:00
1be5f8cd18 chore(ideai): #77 en QA, ouverture de #78 et #79
Enregistre l'état du registre de tickets : #77 entre en QA, #78 ouvre la
dette UX de langue de l'écran Settings et #79 le test flaky relevé en
cours de route.

État runtime uniquement : aucun code de feature n'est touché.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:27:06 +02:00
3f1e132e88 feat(frontend): écran Appareils et parcours d'appairage nommé (#77 F1/F2)
Expose la gestion des appareils appairés introduite en B1-B4, sur une
surface unique partagée par le web et le desktop.

- Écran Appareils : liste, renommage, révocation unitaire ou globale,
  activité formatée, panneau de code éphémère.
- Le parcours d'appairage demande un nom d'appareil, pour qu'une
  révocation porte sur quelque chose d'identifiable par l'utilisateur.
- Gateways DeviceGateway en trois adapters (Tauri, HTTP, Mock), le port
  restant le seul contrat connu de la feature.

Les erreurs sont mappées localement et le message du serveur n'est jamais
affiché tel quel : un échec d'appairage ne doit pas devenir un oracle pour
qui teste des codes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:27:06 +02:00
8fe93d1652 feat(backend): appareils appairés persistants, révocables et code éphémère (#77 B1-B4)
L'appairage ne survivait pas au redémarrage et son code, permanent, était
imprimé sur la sortie standard. Un appareil appairé devient une entité
persistante, nommée et révocable, derrière un code désormais éphémère.

- B1 : port DeviceSessionStore et adapter FsDeviceSessionStore, entités de
  domaine (PairedDevice, DeviceId, SessionTokenHash, DeviceName). Les tokens
  sont hachés en SHA-256 et comparés en temps constant (subtle) : le store
  ne peut pas rejouer une session qu'il a servie. Cookie Max-Age 400 j à
  renouvellement glissant, lastSeenAtMs throttlé.
- B2 : code éphémère en mémoire, TTL 10 min et usage unique, toute
  génération invalidant la précédente. POST /api/pairing-code authentifiée,
  flag --new-code. Le code est retiré du boot et l'eprintln! qui l'imprimait
  est supprimé.
- B3 : endpoints devices (list/rename/revoke/revoke-all/logout), event
  DeviceRevoked et ActiveConnectionRegistry par device_id, fermant sans
  délai les WebSockets d'un appareil révoqué.
- B4 : port PairAttemptLimiter et adapter mémoire, rate-limit par origine et
  global sur horloge injectée, donc testable sans attente réelle.

La normalisation du code passe côté serveur : elle absorbe la dette #76, que
la seule normalisation frontend de #75 ne faisait que masquer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:26:56 +02:00
9463b7e6bf Merge feature/ticket75-pairing-code-keyboard into develop
Intègre le ticket #75 : l'écran d'appairage web appelait le pavé
numérique des mobiles alors que le code d'appairage est de
l'hexadécimal majuscule, rendant les lettres A-F non saisissables.

Le champ passe en clavier texte, annonce le format réel et normalise
la saisie avant envoi. Le correctif revient sur le choix de #69, qui
avait supposé un code purement numérique.

Suite verte rejouée avant merge : 88 fichiers, 801 tests, tsc exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:41:01 +02:00
481a7e2498 chore(ideai): clôture de #74 et #68, ouverture de #75 et #76
Enregistre l'état du registre de tickets : #74 et #68 passent en closed
après la validation live de l'intégration web, #75 (ce fix) entre en QA
et #76 ouvre la dette de casse du code d'appairage côté serveur.

État runtime uniquement : aucun code de feature n'est touché.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:40:26 +02:00
120d0d1ac2 fix(frontend): clavier texte et normalisation du code d'appairage (#75)
Le code d'appairage est de l'hexadécimal majuscule (p. ex. 3F7A9C21),
pas une suite de chiffres. Le champ appelait pourtant le pavé numérique
des mobiles, rendant les lettres A-F non saisissables.

Le champ passe donc en inputMode="text" avec autoCapitalize="characters",
ce qui revient sur le choix inverse fait en #69 : ce ticket avait supposé
un code purement numérique. Le placeholder et l'aide du champ décrivent
désormais le format réel.

La saisie est normalisée (majuscules, espaces retirés) avant pair() car
le serveur compare le code strictement ; la dette correspondante côté
backend est suivie en #76.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:40:26 +02:00
aec4293750 Merge feature/ticket74-web-transport-bundle into develop
Intègre les tickets #68 (packaging des assets web en ressource Tauri) et
#74 (le serveur embarqué servait le bundle desktop au navigateur).

Le frontend produit désormais deux artefacts Vite distincts : dist
(transport tauri, build.frontendDist) et dist-web (transport http,
packagé en ressource « web/ »). Le garde-fou de build échoue si un
bundle résout le mauvais transport.

Les deux tickets ferment ensemble : le packaging #68 servait le bundle
cassé, il ne pouvait pas être déclaré vert sans le fix #74. Validation
live utilisateur verte derrière reverse proxy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:34:32 +02:00
4de25d2bce chore(ideai): état runtime du ticket #74 (passage en QA)
Enregistre le passage de #74 en statut QA (issue, carnet, index) et
l'accusé de complétion des rendez-vous headless du lot.

État runtime uniquement : aucun code de feature n'est touché.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:34:24 +02:00
f605989fa5 chore(ideai): note mémoire du seam de transport des bundles (#74)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:09:46 +02:00
25728dee74 test(frontend): garde-fou sur le transport des bundles construits (#74 Q1)
Vérifie sur les artefacts réels que `dist` résout `tauri` et `dist-web`
`http`, en lisant le marqueur `__IDEA_TRANSPORT__` des assets JS émis. La
régression de #74 était invisible aux tests unitaires : elle vivait dans la
conf de build et de packaging, pas dans le code testé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:09:42 +02:00
5ca2ab77f3 fix(app-tauri): packager le bundle web, pas le bundle desktop (#74 B1)
`bundle.resources` packageait `frontend/dist` — le bundle desktop — sous
`web/`, que `resolve_web_root()` sert au navigateur. La ressource pointe
désormais sur `frontend/dist-web` (transport http), et `beforeBuildCommand`
appelle `build:bundle` pour que les deux bundles existent au packaging.

`frontendDist` reste sur `dist` : le desktop est inchangé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:09:42 +02:00
a7197fc53b feat(frontend): produire un bundle web distinct en transport http (#74 F1)
Le transport est figé au build par Vite : un seul `dist` ne peut pas servir
à la fois le desktop (IPC Tauri) et le navigateur (HTTP+WS). Le serveur
embarqué servait donc un bundle desktop, d'où `__TAURI_INTERNALS__ is
undefined` côté navigateur.

- `vite.config.ts` devient une factory `({ mode })` : le mode par défaut émet
  le bundle desktop (`dist`), `--mode web` lit `.env.web` et émet le bundle
  navigateur (`dist-web`).
- `.env.web` porte `VITE_TRANSPORT=http` dans un fichier de mode plutôt qu'en
  préfixe de commande, syntaxe qui n'existe pas sous Windows (bundle NSIS).
- `transport.ts` extrait le prédicat `transportFromEnv()`, partagé par l'app et
  la config de build : le constant `__IDEA_TRANSPORT__` et le transport résolu
  dérivent de la même variable via le même prédicat, ils ne peuvent pas diverger.
- `main.tsx` publie `__IDEA_TRANSPORT__` sur `window` : l'affectation est un
  effet de bord, elle survit à la minification et rend un bundle identifiable
  sans grep d'un symbole minifié. Les deux jeux d'adapters étant présents dans
  les deux bundles, leur présence ne prouve rien.

Le chemin desktop est inchangé : le web reste opt-in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:09:33 +02:00
50d237e836 chore(ideai): ouverture du ticket #74 (bundle desktop servi au navigateur)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 08:54:19 +02:00
166adc3c4b feat(app-tauri): packager les assets web en ressource et les résoudre depuis le resource dir (#68)
L'AppImage ne pouvait pas servir d'assets web : `resolve_web_root` ne
connaissait que `IDEA_WEB_ROOT`, le dossier de l'exécutable et le cwd —
aucun ne pointe vers un bundle packagé.

- `bundle.resources` embarque les assets sous `web/`, `beforeBuildCommand`
  déclenche le build du frontend.
- Le resource dir Tauri descend de `lib.rs` jusqu'à `EmbeddedServerController`
  via `AppState::build_with_resource_dir`, en gardant les constructeurs
  historiques comme façade.
- `resolve_web_root` insère le candidat packagé juste après `IDEA_WEB_ROOT`,
  qui garde donc la priorité pour le développement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 08:54:15 +02:00
a13a6c1801 chore(ideai): carnet de #68, passage en QA et note du faux vert sandbox
Dernier état `.ideai/` du sprint serveur embarqué, séparé du code applicatif
conformément aux précédents `f965f64` / `7fbaa8e` / `5ee25d1`.

- #68 passé en `qa`, pas en `closed` : mergé dans `develop` (`b30c9c7`) et vert,
  mais RIEN n'a été cliqué dans l'app réelle. « Mergé » ne veut pas dire
  « validé » — même distinction que pour #69 et son lot 3.
- Carnet de #68 : périmètre livré, réserve « aucune validation live », et la
  dette identifiée (ordre de `preview_settings` qui verrouille la liste des
  candidats LAN, warning `missingTrustedProxy` inatteignable).
- Mémoire projet : note `sandbox-eperm-bind-false-green-web-server`. Elle
  capitalise le piège qui s'est refermé DEUX FOIS le 2026-07-16 : les sandboxes
  de DevBackend et QA refusent `TcpListener::bind`, donc tout `cargo test` sur
  `web-server`/`app-tauri` y rend un vert qui ne prouve rien. La première fois,
  ce faux vert masquait un vrai bug produit — un serveur embarqué sur port
  éphémère rejetait toutes les requêtes API en 403. La règle qui en sort : aucun
  repli EPERM silencieux, et tout vert sur ces crates doit être produit hors
  sandbox.
- Journal des tâches de fond : complétions enregistrées.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 06:03:11 +02:00
f965f64b07 chore(ideai): clôture de #72 (confiance reverse proxy)
#72 passé en `closed` : mergé dans `develop` via `ae01297`, vert hors sandbox,
branche supprimée. Carnet complété avec le périmètre livré et les arbitrages.

État `.ideai/` indépendant du code de #68, committé directement sur `develop`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:44:12 +02:00
b30c9c7590 Merge feature/ticket68-embedded-server-desktop into develop (#68)
Ferme #68 : le serveur web s'active désormais depuis l'app desktop, via un
panneau Settings > Deployment. C'est la fonctionnalité demandée à l'origine du
ticket, et l'aboutissement de la chaîne #65 (extraction d'idea-serve) → #68 B1
(seam shared-core) → #72 (durcissement proxy) → ce lot.

Backend : `EmbeddedServerController` sur `AppState`, commandes start/stop/status,
store `<app-data-dir>/deployment/server-exposure.json` (config de sécurité, pas
préférence d'UI), arrêt câblé sur la sortie d'app. Le serveur embarqué consomme
`run_embedded_with_core` avec le `BackendCore` du desktop — une seule
composition root, ce pour quoi le seam de B1 existait.

Frontend : `features/settings/` avec `SettingsView`/`DeploymentSettings`,
gateway `DesktopServerGateway`, et `ProjectsView` dont le booléen `showSettings`
devient une vraie navigation `AI Profiles` / `Deployment`.

Vert avant merge, ré-exécuté par Git HORS SANDBOX — impératif sur ces crates,
le sandbox bloque `bind` et a déjà produit un faux vert sur #68 B1 :
`cargo test -p app-tauri` 43 passed / 1 ignored · `-p web-server` 66 passed ·
`npm run typecheck` exit 0 · `npx vitest run` 87 files / 789 passed. Le test
ignoré est une garde d'environnement préexistante, vérifiée passante hors
sandbox avec `--ignored`.

Invariants vérifiés par Git avant merge : `run_embedded_with_core` bien
l'appelant, aucune fuite des types d'exposition dans `domain`/`application`,
`0600` posé avant le `rename`, `stop()` appelé à la fermeture.

Dette consignée, non bloquante : `preview_settings` valide avant de
prévisualiser (verrouille la liste des candidats LAN, contourné côté frontend),
et le warning `missingTrustedProxy` est inatteignable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:43:56 +02:00
7efa634f23 feat(frontend): panneau Settings Deployment et gateway serveur desktop (#68 F1+F2)
Donne à l'utilisateur la surface pour activer le serveur depuis l'app.

- `features/settings/` : `SettingsView`, `DeploymentSettings`, `useDeployment`.
- `adapters/desktopServer.ts` + port `DesktopServerGateway` et DTO associés ;
  adapters mock et http/unsupported alignés (le mode web n'expose pas le
  contrôle du serveur qui l'héberge).
- `ProjectsView` : le `showSettings: boolean` devient une navigation interne
  `AI Profiles` / `Deployment`. Le libellé alternant « Close AI Profiles »
  disparaît — Settings existait déjà dans cette vue, la surface évolue au lieu
  d'ajouter un `PanelId`.

CORRECTION D'UN BRIEF FAUX, remontée spontanément par DevFrontend et qui mérite
de survivre : le cadrage décrivait le mode `remoteProxyOtherMachine` avec deux
champs. `validate_settings` en exige un troisième, `lanBindAddress`, et rejette
loopback comme unspecified. Construit selon la spec, chaque save et chaque start
en mode 3 aurait échoué — le lot serait parti vert et cassé. Le panneau expose
donc un select alimenté par `candidateLanAddresses` fourni par le backend :
la règle « le frontend n'invente jamais une IP » tient.

QA ré-exécutée par Git avant merge : `npm run typecheck` exit 0 ·
`npx vitest run` 87 files / 789 passed, 0 échec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:43:37 +02:00
49e1d5a157 feat(app-tauri): cycle de vie du serveur embarqué et persistance de l'exposition (#68 B2)
Permet d'activer le serveur web depuis l'app desktop — la fonctionnalité
demandée à l'origine du ticket.

- `embedded_server.rs` : `EmbeddedServerController` porté par `AppState`, avec
  les commandes `embedded_server_start` / `stop` / `status` et
  `get` / `save` / `preview_server_exposure_settings`.
- `FsServerExposureSettingsStore` : écrit `<app-data-dir>/deployment/server-exposure.json`.
  C'est de la config de sécurité, pas une préférence d'UI — d'où l'app-data-dir
  global plutôt que `localStorage`. Écriture atomique : temporaire, `0600` posé
  AVANT le `rename`, donc le fichier final n'est jamais brièvement lisible par
  tous.
- Arrêt du serveur câblé sur la sortie de l'app (`lib.rs`), aux côtés des autres
  nettoyages.

Le serveur embarqué appelle `run_embedded_with_core` avec le `BackendCore`
partagé du desktop, jamais `run_embedded` — c'est tout l'intérêt du seam ouvert
par B1 : une seule composition root dans le processus, pas deux.

DETTE CONNUE, consignée au carnet, non bloquante :
- `preview_settings` valide avant de prévisualiser (`embedded_server.rs:211`),
  ce qui verrouille la liste des candidats : un brouillon en mode
  `remoteProxyOtherMachine` ne peut pas être prévisualisé tant qu'il n'a pas le
  `lanBindAddress` que cette liste doit justement fournir. Contourné côté
  frontend par une sonde `localOnly` toujours valide. Défaut d'ordonnancement,
  pas une fatalité.
- Le warning `missingTrustedProxy` (`embedded_server.rs:452`) est du code mort :
  `validate_settings` rejette les `trustedProxies` vides en mode 3 avant que
  `preview_settings` ne puisse le produire.

QA ré-exécutée par Git HORS SANDBOX (DevBackend et QA sont bloqués par EPERM sur
`bind` ; un vert sandboxé ne prouve rien sur ces crates) : `cargo test -p
app-tauri` 43 passed, 1 ignored · `-p web-server` 66 passed, 0 échec.
Le test ignoré `mcp_bridge::tests::end_to_end_over_real_loopback` est
PRÉEXISTANT sur `develop` et non touché par ce lot ; lancé explicitement hors
sandbox avec `--ignored`, il passe. C'est une garde d'environnement, pas un faux
vert — distinction qui nous a déjà coûté deux fois aujourd'hui.

Invariants vérifiés par Git : appelant `run_embedded_with_core` confirmé, aucune
fuite d'`EmbeddedServer*`/`ServerExposure*` dans `domain` ni `application`,
ordre chmod/rename correct, `stop()` bien appelé à la fermeture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:43:24 +02:00
7fbaa8eba6 chore(ideai): ouverture de #73 (TLS intégré à idea-serve)
#73 ouvert en priorité haute : intégrer TLS à `idea-serve` pour supprimer la
cause racine de la cérémonie reverse proxy, dont #72 vient de durcir les
garde-fous. Lié à #72, #66, #68 et #71.

État `.ideai/` indépendant du code de #72, committé directement sur `develop`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:11:15 +02:00
ae01297b6b Merge feature/ticket72-proxy-trust-hardening into develop (#72)
Rend réelle la confiance accordée au reverse proxy. Jusqu'ici
`--trust-reverse-proxy` n'était lu que pour exiger sa propre présence : le
serveur ne vérifiait ni qui se connectait, ni ce que le proxy annonçait. Le pair
TCP est désormais vérifié avant toute confiance aux headers, `X-Forwarded-Proto:
https` est exigé en mode proxy, et `X-Forwarded-For` n'autorise jamais rien.

Corrige aussi B0, le pendant CLI du bug de port de #68 B1 : `idea-serve --listen
127.0.0.1:0` ne compare plus l'origine à `http://127.0.0.1:0`.

CHANGEMENT DE COMPORTEMENT ASSUMÉ : un bind non-loopback distant sans
`--trusted-proxy` est refusé au démarrage. Les configurations existantes de cette
forme cassent volontairement, avec un message qui dit quoi ajouter.

Vert avant merge, ré-exécuté par Git HORS SANDBOX — impératif sur ce crate, le
sandbox interdit `TcpListener::bind` et a déjà produit un faux vert sur #68 B1 :
`cargo test -p web-server` 66 passed · `-p backend` · `-p app-tauri` verts.
Revue de sécurité : guard câblé sur les 3 surfaces (statique, /api/*, /api/ws),
vrai `peer_addr` propagé sur tout le chemin de production.

Étape 2 du plan d'intégration séquentiel. Suit : #68 (panneau Settings
Deployment), sur arbitrage utilisateur qui a refusé de le reléguer derrière #71
et #73.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:11:04 +02:00
42804fc55d docs(server): documenter --trusted-proxy et l'exigence X-Forwarded-Proto (#72)
Aligne la doc du mode distant sur le durcissement de #72 : exemple complet du
cas proxy sur une autre machine avec `--trusted-proxy`, mention que tout bind
non-loopback exige au moins un proxy autorisé, et obligation faite au proxy
d'envoyer `X-Forwarded-Proto: https`.

Précise que transmettre le host public est recommandé pour le diagnostic mais
n'est pas la vérification d'accès : celle-ci reste l'égalité stricte d'`Origin`
contre `--public-origin`.

Bascule les IP d'exemple vers la plage de documentation RFC 5737 (`192.0.2.0/24`)
au lieu d'un réseau privé plausible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:10:47 +02:00
75d1ce87fc feat(web-server): donner un effet réel à la confiance reverse proxy + fix port CLI (#72)
`--trust-reverse-proxy` était un drapeau creux : il n'était lu que dans
`ServerConfig::validate()` pour exiger sa propre présence. Le serveur n'a jamais
vérifié qui se connectait ni ce que le proxy annonçait. Ce lot lui donne un
effet.

- `trusted_proxies: Vec<TrustedProxy>` dans `ServerConfig` + `--trusted-proxy`
  (répétable, IP ou CIDR v4/v6). `validate()` refuse désormais au démarrage un
  bind non-loopback en mode remote sans au moins un proxy autorisé.
- Guard runtime sur les trois surfaces (statique, `/api/*`, `/api/ws`) : le
  `peer_addr` réel est vérifié AVANT toute confiance accordée aux headers. Sur
  bind loopback, seul un pair loopback est accepté ; sinon le pair doit tomber
  dans un `--trusted-proxy`.
- `X-Forwarded-Proto: https` obligatoire en mode proxy, avec un message de refus
  actionnable (la commande nginx exacte à ajouter).
- `X-Forwarded-For` n'autorise jamais rien : il n'est que journalisé. Un header
  ne donne aucun droit, seul le pair TCP en donne.
- Diagnostics : `UntrustedProxyPeer`, `ForwardedProtoRejected`,
  `ForwardedHostMismatch`.

B0 — même bug de port que celui corrigé dans #68 B1, sur le chemin CLI cette
fois : `run_server` construisait son `ServerState` AVANT le bind, donc
`idea-serve --listen 127.0.0.1:0` comparait l'origine à `http://127.0.0.1:0` et
rejetait tout en 403. La réconciliation du port effectif est factorisée dans
`config_with_effective_listen`, partagée avec le chemin embarqué.

`X-Forwarded-Host` ne provoque PAS de rejet : Architect l'a jugé redondant avec
la vérification stricte d'`Origin`, et il cassait nginx en configuration par
défaut. Il reste un warning de diagnostic. La vérification d'accès stricte
demeure l'égalité d'`Origin` contre `--public-origin`.

CHANGEMENT DE COMPORTEMENT ASSUMÉ — ce lot casse volontairement les
configurations existantes : un bind non-loopback distant sans `--trusted-proxy`
est désormais refusé au démarrage. C'est le prix d'un drapeau qui ne mentait
plus. Le message d'erreur dit quoi ajouter.

Origine du ticket : #72 est né d'une trouvaille de l'agent Git à la revue de la
doc de #65 — c'est en vérifiant une phrase de sécurité qu'il a établi que
`--trust-reverse-proxy` n'avait aucun effet runtime.

QA ré-exécutée par Git HORS SANDBOX avant merge (DevBackend et QA sont tous deux
bloqués par EPERM sur `TcpListener::bind` ; un vert sandboxé ne vaut rien ici, on
s'est déjà fait avoir sur #68 B1) : `cargo test -p web-server` 66 passed, 0
échec · `-p backend` · `-p app-tauri` verts.

Revue de sécurité par Git : guard câblé sur les 3 routes, chemin de production
propageant toujours le vrai `peer_addr` (le repli `listen.ip()` est
`#[cfg(test)]`, inatteignable en production), CIDR correct y compris `prefix 0`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:10:40 +02:00
e8a72f5173 Merge docs/remote-mode-proxy-clarification into develop (#65)
Corrige une documentation qui a réellement induit l'utilisateur en erreur :
l'exemple du cas « Remote HTTPS » bindait `127.0.0.1` en supposant sans le dire
que le reverse proxy est co-localisé. Suivi tel quel avec un proxy sur une autre
machine, il produit un serveur injoignable.

Précise le cas co-localisé vs le cas distant, la configuration exigée pour tout
bind non-loopback, l'égalité stricte de `--public-origin` contre l'`Origin`
(ouvrir l'UI par le domaine, pas par l'IP, sinon 403), et le pare-feu hôte comme
cause de timeout depuis les autres machines.

Conserve l'obligation normative « ne pas exposer sans proxy TLS » que la
première rédaction avait perdue au profit d'une garantie que le code n'offre
pas : `ServerConfig::validate()` valide la configuration, pas la présence réelle
d'un proxy.

Indépendant de #68 et #71 : n'a attendu aucun de leurs lots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:51:23 +02:00
677f64d18e docs(server): restaurer l'obligation de proxy TLS, que validate() n'assure pas (#65)
La réécriture précédente avait remplacé « Do not expose the backend on a public
non-loopback address without TLS proxying » par une formulation adossée à
`ServerConfig::validate()`. Littéralement vraie, mais pas équivalente : elle
troque une OBLIGATION faite à l'opérateur contre une garantie automatique qui
n'existe pas.

`validate()` valide une configuration, pas la réalité. Rien ne vérifie qu'un
proxy est réellement devant le serveur : passer les trois drapeaux en bindant
`0.0.0.0` sans aucun proxy satisfait `validate()` et sert du HTTP en clair.
La phrase normative est donc rétablie, avec cette limite dite explicitement.

Voir #72 : `--trust-reverse-proxy` n'a par ailleurs aucun effet à l'exécution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:51:11 +02:00
f915635e07 chore(ideai): ouverture de #72 (confiance reverse proxy sans effet réel)
- #72 ouvert en priorité haute, bloquant #68 : `--trust-reverse-proxy` n'est lu
  que dans `ServerConfig::validate()` pour exiger sa propre présence — le serveur
  ne parse jamais `X-Forwarded-*`. C'est un drapeau d'intention pur, qui donne un
  faux sentiment de protection. Le ticket porte aussi B0 (le bug de port effectif
  sur le chemin CLI `run_server`), `trusted_proxies` + `--trusted-proxy`, le
  guard runtime et les événements de diagnostic.
- Journal des tâches de fond : complétions enregistrées.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:50:57 +02:00
7c677c4fb4 docs(contexte): ajouter le rôle UX au contexte projet et au cycle obligatoire
Déclare UX comme propriétaire de la conception des surfaces (navigation,
libellés, hiérarchie de l'information, formulation des erreurs, parcours) et
l'insère dans le cycle : avant Architect quand la forme conditionne les contrats,
avec lui quand une contrainte technique borne la conception. UX ne bloque pas les
lots sans surface utilisateur.

Ajoute aussi l'avertissement que la liste des rôles de ce document n'est pas la
source de vérité des agents déclarés : `idea_list_agents` fait autorité.

Contexte projet, indépendant des chantiers en cours — committé directement sur
`develop`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:50:50 +02:00
328c941f22 Merge feature/ticket68-embedded-server-core into develop (#68 B1)
Livre la fondation backend du serveur embarqué desktop : le seam
`run_embedded_with_core(config, Arc<BackendCore>)`, qui permet à l'app Tauri de
faire tourner le serveur web en partageant son composition root plutôt qu'en
dupliquant un backend dans le même processus.

Apporte aussi le fix du port effectif : un serveur embarqué sur port éphémère
rejetait toutes les requêtes API en 403 (origine comparée à un `listen` resté à
port 0). Et comble le trou de couverture sur `run_embedded().stop()` signalé au
merge de #65.

Vert avant merge, ré-exécuté par Git HORS SANDBOX — impératif ici : le sandbox
interdit `TcpListener::bind` et avait produit un faux vert sur ce lot précis.
`cargo test -p web-server` 57 passed (les 2 tests embedded réellement exécutés) ·
`-p backend` 41 · `-p app-tauri` 35, 0 échec.

Étape 1 du plan d'intégration séquentiel. Suit : #72 (durcissement bind/origine/
proxy, dont B0 = le même bug de port sur le chemin CLI `run_server`), puis #71
lot 1 (diagnostics), puis le Settings Deployment de #68.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:50:37 +02:00
b7d38f1d30 feat(web-server): seam shared-core run_embedded_with_core + fix du port effectif (#68 B1)
Ouvre le point d'intégration du serveur embarqué desktop : `run_embedded_with_core`
reçoit un `Arc<BackendCore>` déjà construit, pour que l'adapter HTTP partage le
composition root de l'adapter Tauri au lieu d'en bâtir un second dans le même
processus. `ServerState` porte désormais `Arc<BackendCore>` ; `run_embedded` est
conservé en compat et délègue en construisant son propre core.

Corrige au passage un vrai bug produit, découvert en rejouant les tests hors
sandbox : `run_embedded` ne réconciliait pas le port effectif avec la config. Sur
un bind éphémère (`127.0.0.1:0`), l'état du serveur gardait `listen` à port 0,
donc `origin_allowed` comparait l'Origin entrante à `http://127.0.0.1:0` et le
serveur embarqué rejetait **toutes** les requêtes API en 403. La config reçoit
maintenant `local_addr` lu sur le listener **avant** la construction du state.

Comble le trou signalé au merge de #65 : `run_embedded().stop()` est enfin
couvert.

ATTENTION — le premier « 57 passed » de ce lot était un FAUX VERT. Le sandbox
d'exécution interdit `TcpListener::bind` ; les tests sortaient en silence sur
EPERM, dont précisément le test `stop()`. Les replis EPERM sont supprimés : un
bind refusé fait désormais échouer le test au lieu de le peindre en vert.

QA ré-exécutée par Git HORS SANDBOX avant merge (sinon on reproduit le faux
vert) : `cargo test -p web-server` 57 passed, 0 échec, avec
`run_embedded_stop_shuts_down_accept_loop` et
`run_embedded_with_core_uses_injected_core_for_http_invokes` réellement exécutés
sur le vrai chemin réseau. `-p backend` 41 · `-p app-tauri` 35, 0 échec.

DETTE CONNUE, tracée en #72 B0 : le chemin CLI standalone `run_server` garde la
même hypothèse fausse — il reçoit un `ServerState` construit AVANT le bind, donc
`idea-serve --listen 127.0.0.1:0` reste cassé à l'identique.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:50:18 +02:00
77684ea183 docs(server): clarifier le bind du cas proxy distant et l'origine stricte (#65)
L'exemple du cas « Remote HTTPS » bindait `127.0.0.1` en supposant sans le dire
que le reverse proxy tourne sur la même machine que le serveur. Suivi tel quel
avec un proxy sur une autre machine, il produit un serveur injoignable — ce qui
a réellement induit l'utilisateur en erreur aujourd'hui.

Précise donc : le cas co-localisé vs le cas proxy distant (binder une adresse
joignable par le proxy, lien proxy→serveur en HTTP clair sur le LAN), la
configuration exigée pour tout bind non-loopback, l'égalité stricte de
`--public-origin` contre l'`Origin` de la requête (ouvrir l'UI par le domaine et
non par l'IP, sinon 403), et le pare-feu hôte comme cause de timeout depuis les
autres machines.

Indépendant de #68 et #71 : valeur immédiate, n'attend aucun de leurs lots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:11:56 +02:00
56b9f5f619 chore(ideai): clôture de #65 et ouverture de #71 (diagnostic d'accessibilité)
État `.ideai/` indépendant des branches de feature, committé directement sur
`develop` (précédents `2fa226e`, `56757c7`).

- #65 « serveur headless idea-serve » passé en `closed` par Main : mergé dans
  `develop` via `fe0e53e`, vert, branche supprimée.
- #71 « diagnostic d'accessibilité du serveur » ouvert, lié à #68 et #65.
- Journal des tâches de fond : complétions enregistrées.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:11:38 +02:00
32c670a2b3 Merge feature/ticket69-mobile-responsive-client into develop (#69)
Rend le client web utilisable sur téléphone : `100dvh` + `viewport-fit=cover` et
safe-area insets (la barre d'URL rétractable masquait le bas), rangées qui
s'empilent sous 360px, et surtout une `TerminalKeyBar` (Esc/Tab/Ctrl-C/Ctrl-D/
flèches) qui rend le terminal pilotable au doigt — le clavier virtuel n'offre
aucune de ces touches, on pouvait donc parler à un agent CLI mais pas
l'interrompre.

Surface frontend-pure : aucun DTO, use case ni `LayoutTree` touché. Le chemin
desktop est inchangé (`LayoutGrid` ne passe pas `onReady`, `onReady` est
optionnel).

Vert avant merge, ré-exécuté par Git sur la base rebasée (les chiffres du carnet
ne valaient que sur `506d589`) : `npm run typecheck` exit 0 · `npx vitest run`
85 files / 774 passed, 0 échec · `VITE_TRANSPORT=http npx vite build` exit 0.

RÉSERVE — le lot 3 est livré mais NON PROUVÉ en conditions réelles.
La validation live utilisateur du 2026-07-16 (téléphone réel, via le reverse
proxy) est PARTIELLE. Couvert : le terminal s'ouvre, ce qui implique appairage +
workspace traversés, donc les lots 1 et 2 (`100dvh`, `60dvh`) validés en réel.
NON couvert : la `TerminalKeyBar` n'a pas été utilisée, et l'invariant de focus
au tap (« chaque tap annule le déplacement de focus puis refocalise xterm », sans
lequel le clavier virtuel se referme à chaque touche) n'a pas été observé sur
navigateur mobile réel. C'est le cœur du ticket. Le test manquant tient en 30 s :
lancer une commande longue, taper Ctrl-C dans la barre, vérifier que la commande
s'interrompt ET que le clavier reste ouvert.
Merge autorisé par Main sur ce constat : risque résiduel borné (frontend-pur,
desktop non atteint, non-régression prouvée). Si le test Ctrl-C échoue, il ouvre
un bug, il ne réverte pas ce merge. Jusque-là, « mergé » ne veut pas dire
« validé ».

Trou de couverture connu et assumé : jsdom n'évalue pas les media queries, les
tests sont structurels et ne prouvent ni les breakpoints, ni `dvh`, ni les safe
areas, ni le clavier virtuel. La décision d'ajouter un vrai navigateur
(Playwright) appartient à #63 « Système de test de l'UI ».

Rebase préalable sur `develop@fe0e53e` (#65 avait fait avancer la base) :
sans conflit, aucun recouvrement de fichiers, arbre `frontend/` identique bit
pour bit à l'original `6ed0087`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:16:21 +02:00
24f5e85974 chore(ideai): carnet de #69 — validation live partielle et réserve du lot 3
État `.ideai/` du chantier client téléphone, séparé du code applicatif
conformément aux précédents `5ee25d1` / `8e481ae`.

- Carnet #69 : périmètre exact de la validation live utilisateur sur téléphone
  réel (lots 1 et 2 validés en réel via l'appairage puis le workspace ; lot 3 —
  `TerminalKeyBar` et invariant de focus — NON exercé), arbitrage Main
  autorisant le merge avec réserve écrite, et report de l'écart Playwright
  vers #63 plutôt que vers un ticket neuf.
- Faits de topologie mis à jour après rebase sur `develop@fe0e53e` : les SHA
  cités (`e22ea5b`/`8f15ad6`/`6ed0087`) n'existaient plus sur aucune branche.
- Journal des tâches de fond : complétions enregistrées.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:15:45 +02:00
f0f2f3b497 fix(frontend): retirer l'import vi inutilisé du test de la key bar (#69)
`npm run build` est `tsc --noEmit && vite build` : un import non utilisé
casse le build (TS6133), pas seulement le lint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:13:22 +02:00
62ecefe1e8 feat(frontend): terminal pilotable au doigt sur téléphone (#69)
Lots 3 et 4 du ticket #69. Sans ça un agent CLI est indriveable depuis un
téléphone : le clavier virtuel n'a ni Esc, ni Tab, ni Ctrl, ni flèches, donc
on peut taper un prompt mais pas l'interrompre, compléter un chemin, sortir
d'un éditeur ou rappeler l'historique.

- `TerminalView` expose un `onReady(api)` optionnel (donc desktop inchangé,
  et inerte quand xterm ne monte pas). `api.send` passe par `term.input()` :
  le *même* chemin qu'une frappe réelle, donc le relais PTY, le comptage de
  lignes et la suspension du write-portal s'appliquent à l'identique. Écrire
  sur le handle aurait court-circuité le portal.
- `TerminalKeyBar` (web-only) : Esc/Tab/Ctrl-C/Ctrl-D/flèches en chips
  tactiles 44px, scroll horizontal. Chaque tap annule le déplacement de
  focus et refocalise xterm — sinon le clavier virtuel se referme à chaque
  touche.
- La cellule agent passe de `h-64` fixe (une letterbox d'~20 lignes) à
  `h-[60dvh]` sur téléphone, `sm:h-80` au-delà.
- Tests : séquences d'octets émises, préservation du focus, état désactivé
  avant montage de xterm, et garde de non-régression sur la frontière —
  le client web ne monte aucun layout-grid/split/dock desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:13:22 +02:00
48b8853214 feat(frontend): shell web responsive pour viewport téléphone (#69)
Lots 1 et 2 du ticket #69 : rendre le client web (#13) utilisable sur un
téléphone. Le client web n'a jamais eu de docks ni de fenêtres flottantes —
il est déjà une colonne verticale unique — donc le lot 2 se réduit aux
rangées qui débordaient réellement à 360px.

- `100dvh` sur html/body/#root : `height: 100%` se résout sur le *large*
  viewport, donc la barre d'URL rétractable d'un navigateur mobile masquait
  le bas de l'app. Desktop inchangé (dynamic == large viewport).
- `viewport-fit=cover` + padding `env(safe-area-inset-*)` sur le header et
  le workspace : l'app peut peindre sous une encoche sans perdre ses
  contrôles. Le zoom reste libre (accessibilité).
- Padding responsive (`p-4 sm:p-6`) : 48px de gouttière sur 360px, c'était
  13% de la largeur.
- `AgentLiveRow` et la rangée de tâche de fond empilent leurs contrôles sur
  téléphone (nom / badges / actions) et retrouvent leur ligne unique dès
  `sm` — à 360px les boutons Cancel+Retry ne tenaient pas.
- Code d'appairage : `inputMode="numeric"`, pas d'autocapitalisation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:13:22 +02:00
fe0e53ec59 Merge feature/ticket65-idea-serve-headless into develop (#65)
Intègre le serveur headless `idea-serve` : le cœur (use cases, DTO, events)
devient partagé entre deux driving adapters, le desktop Tauri et le crate
`web-server` autonome, sans dépendance GUI.

Vert avant merge : `cargo test -p backend` 41 · `-p web-server` 55 ·
`-p app-tauri` 35, 0 échec, ré-exécuté sur la branche. Invariant headless
vérifié (`ldd idea-serve` sans webkit/gtk/tauri) et validation live utilisateur
du serveur (démarrage, SPA servi, contrôle d'origine strict, accès distant).

Débloque #68 (activer le serveur depuis le desktop, via `run_embedded`) et #66
(Docker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:02:28 +02:00
5ee25d1ef6 chore(ideai): état d'orchestration du sprint serveur headless (#65, #68-#70)
État durable de `.ideai/` accumulé pendant le chantier `idea-serve`, séparé du
code applicatif conformément aux précédents `8e481ae` / `ad1f225`.

- Tickets : #13 clôturé (server/client mode livré), #65 passé en QA avec son
  carnet de chantier complet (arbitrages Architect sur le propriétaire canonique
  des DTO, structure livrée, vérif QA, incident de topologie et sa leçon).
  #64/#66/#67 rattachés au sprint. Nouveaux tickets #68 (activer le serveur
  depuis le desktop, dépend de #65), #69 (adaptabilité client téléphone, en QA)
  et #70 (gestion des modèles locaux llama.cpp).
- Mémoire : note `web-client-is-single-column-no-desktop-shell` — le client web
  n'a pas de shell desktop, à lire avant tout cadrage responsive sur cette
  surface.
- Journal des tâches de fond : complétions enregistrées.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:02:14 +02:00
4f57e5a0db chore(gitignore): ignorer la sortie du build web frontend/dist-web (#65)
Le build web (`VITE_TRANSPORT=http`) que sert `idea-serve` produit
`frontend/dist-web/`, remonté comme non suivi car la règle existante n'ancrait
que `frontend/dist/`. C'est de la sortie de build (~1 Mo, rebuildable depuis les
sources) : même classe que `dist/`, elle n'a pas sa place dans l'historique.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:01:40 +02:00
0e15482ce2 feat(web-server): extraire le serveur headless idea-serve du binaire Tauri (#65)
Sort le serveur HTTP/WS de `app-tauri` vers un crate `web-server` autonome,
exposant le binaire `idea-serve` qui sert le client web sans aucune dépendance
GUI. Le cœur (use cases, DTO, events) devient partagé entre deux driving
adapters : le desktop Tauri et le serveur headless.

Structure :
- `crates/web-server` : lib + bin `idea-serve`, consomme `backend::{dto, events}`.
- `crates/backend` : `dto.rs` et `events.rs` deviennent le propriétaire canonique
  des DTO/events transport-neutres (arbitrage Architect : pas de crate contrat
  dédié, `backend` est déjà le composition root transport-neutre).
- `crates/app-tauri` : `dto.rs` réduit à un shim de ré-export, `events.rs` réduit
  au relais Tauri (souscription au bus + emit), `server.rs` vidé au profit du
  crate partagé.
- `Cargo.toml`/`Cargo.lock` : `web-server` ajouté aux membres du workspace.

Frontière respectée : les DTO de `backend` ne tirent ni Tauri, ni HTTP/WS, ni
Axum/Hyper, ni UI ; `domain`/`application` n'en dépendent pas. Le frontend est
inchangé (`git diff 506d589...HEAD -- frontend` vide).

`web_server::run_embedded(...)` est le point d'extension prévu pour #68 (le
desktop consommera `web-server` comme lib plutôt que d'en forker le serveur).

QA (exécution réelle, re-vérifiée avant merge) :
- `cargo test -p backend` 41 passed · `-p web-server` 55 passed · `-p app-tauri`
  35 passed, 0 échec.
- Invariant headless : `ldd target/debug/idea-serve` ne tire aucun
  webkit/javascriptcore/gtk/gdk/soup/tauri/wry, là où `app-tauri` les tire bien.
- Validation live utilisateur : `idea-serve` démarre, sert le SPA, le contrôle
  d'origine strict tient, accès distant OK via reverse proxy.
- Les 10 échecs `openai_compat` de `cargo test --workspace` sont préexistants sur
  `506d589` (bind réseau interdit par la sandbox), hors périmètre.

Squash de `82e8e77` (commit de sûreté « état intermédiaire non figé », qui
rapatriait le travail réalisé par erreur sur la branche de #69) et de `ddbea7b`
(déduplication des DTO events, comblant l'écart annoncé par le premier). Les deux
n'avaient de sens qu'ensemble : ce commit fige le contrat que le premier laissait
explicitement ouvert. Arbre identique bit pour bit à `ddbea7b` ; historique
d'origine conservé sur `backup/ticket65-pre-squash-ddbea7b`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:01:18 +02:00
131 changed files with 20517 additions and 9112 deletions

3
.gitignore vendored
View File

@ -9,6 +9,9 @@ target/
# Dependencies and build output (package-lock.json IS committed). # Dependencies and build output (package-lock.json IS committed).
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
# Sortie du build web (VITE_TRANSPORT=http) servie par idea-serve : meme
# classe que dist/, rebuildable depuis les sources — not versioned (#65).
frontend/dist-web/
# Root-level node_modules (dev tooling installs at repo root — never versioned). # Root-level node_modules (dev tooling installs at repo root — never versioned).
/node_modules/ /node_modules/
# npm/yarn/pnpm debug logs # npm/yarn/pnpm debug logs

File diff suppressed because one or more lines are too long

View File

@ -62,3 +62,6 @@
- [ticket13-f5-web-live-surfaces-delivered](ticket13-f5-web-live-surfaces-delivered.md) — Les surfaces live web (workstate live via event.domain, background tasks cancel/retry, inbox, re-synchro au reconnect) du chantier client/serveur #13 sont livrées et vertes. - [ticket13-f5-web-live-surfaces-delivered](ticket13-f5-web-live-surfaces-delivered.md) — Les surfaces live web (workstate live via event.domain, background tasks cancel/retry, inbox, re-synchro au reconnect) du chantier client/serveur #13 sont livrées et vertes.
- [frontend-uses-npm-not-pnpm](frontend-uses-npm-not-pnpm.md) — memory note frontend-uses-npm-not-pnpm - [frontend-uses-npm-not-pnpm](frontend-uses-npm-not-pnpm.md) — memory note frontend-uses-npm-not-pnpm
- [idea-distribution-strategy-desktop-vs-docker](idea-distribution-strategy-desktop-vs-docker.md) — memory note idea-distribution-strategy-desktop-vs-docker - [idea-distribution-strategy-desktop-vs-docker](idea-distribution-strategy-desktop-vs-docker.md) — memory note idea-distribution-strategy-desktop-vs-docker
- [web-client-is-single-column-no-desktop-shell](web-client-is-single-column-no-desktop-shell.md) — memory note web-client-is-single-column-no-desktop-shell
- [sandbox-eperm-bind-false-green-web-server](sandbox-eperm-bind-false-green-web-server.md) — memory note sandbox-eperm-bind-false-green-web-server
- [ticket74-f1-web-bundle-transport-seam](ticket74-f1-web-bundle-transport-seam.md) — Deux artefacts Vite (dist/dist-web), mode `web` portable Windows, et le câblage anti-divergence de la constante de transport.

View File

@ -0,0 +1,42 @@
---
name: sandbox-eperm-bind-false-green-web-server
description: memory note sandbox-eperm-bind-false-green-web-server
metadata:
type: project
---
---
name: sandbox-eperm-bind-false-green-web-server
description: Les sandboxes de DevBackend et QA bloquent TcpListener::bind (EPERM) ; tout `cargo test` sur web-server/app-tauri y rend un VERT QUI NE PROUVE RIEN. Vérifier hors sandbox.
metadata:
type: reference
---
# Faux vert : `TcpListener::bind` interdit dans les sandboxes agents
## Le fait
Les environnements d'exécution de **DevBackend et de QA** refusent `TcpListener::bind("127.0.0.1:0")` avec `Operation not permitted (os error 1)`. Tout test qui ouvre un socket y est structurellement inexécutable.
Crates concernés : `web-server`, `app-tauri` (pont MCP, serveur embarqué).
## Pourquoi c'est dangereux, pas juste gênant
Le 2026-07-16, ce piège s'est refermé **deux fois dans la même journée** :
1. **#68 B1** — DevBackend annonce « 57 passed ». En réalité le test `run_embedded_stop_shuts_down_accept_loop` sortait en silence sur EPERM via un repli, et le test du core partagé basculait sur un dispatch in-process. Rejoué **hors sandbox** : échec réel, révélant un **vrai bug produit**`run_embedded` ne réconciliait pas le port effectif avec la config, donc `origin_allowed` comparait l'origine à `http://127.0.0.1:0` et un serveur embarqué sur port éphémère **rejetait toutes les requêtes API en 403**. Le trou n'avait jamais été vu parce que rien ne consommait `run_embedded`.
2. **#72** — même schéma, cette fois signalé honnêtement par DevBackend (« 63 passed; 2 failed » sur EPERM) après consigne explicite.
Un repli silencieux sur EPERM transforme un test en décoration : il passe sans rien exercer, et masque la classe de bug qu'il était censé attraper.
## La règle
- **Aucun repli EPERM silencieux.** Un test qui ne peut pas s'exécuter doit **échouer visiblement** ou être `#[ignore]` explicite avec sa raison — jamais « passer ».
- **Tout vert sur `web-server`/`app-tauri` doit être produit hors sandbox** (`dangerouslyDisableSandbox: true` côté Main, qui n'a pas la restriction). Ne jamais accepter un vert sandboxé comme preuve sur ces crates.
- DevBackend et QA doivent **dire ce qu'ils ont pu exécuter et ce qu'ils n'ont pas pu**, plutôt que de rendre un chiffre global.
- Les `#[ignore = "requires local socket bind permission"]` légitimes (ex. `mcp_bridge::tests::end_to_end_over_real_loopback`) se vérifient en les **lançant** hors sandbox avec `--ignored`, pas en raisonnant dessus.
## Principe général
Ne pas confondre « les tests sont verts » et « le code marche ». Le vert d'un environnement contraint ne dit rien du produit. Cette leçon vaut au-delà du bind : un environnement qui ne peut pas exercer un chemin ne peut pas le valider.
Lien : [[appimage-build-no-strip-relr-dyn-fix]] (autre piège d'environnement de cette machine),
[[rendezvous-600s-cap-too-short-heavy-tasks]].

View File

@ -0,0 +1,18 @@
---
name: ticket74-f1-web-bundle-transport-seam
description: Deux artefacts Vite (dist/dist-web), mode `web` portable Windows, et le câblage anti-divergence de la constante de transport.
metadata:
type: reference
---
Le frontend produit **deux** artefacts Vite depuis les mêmes sources :
- `frontend/dist` → transport `tauri` (desktop, `build.frontendDist`) — `npm run build`.
- `frontend/dist-web` → transport `http` (packagé en ressource Tauri `web/`) — `npm run build:web`.
- `npm run build:bundle` produit les deux : c'est le point d'entrée du packaging Tauri.
**Le mode web passe par `--mode web` + `frontend/.env.web` (`VITE_TRANSPORT=http`), jamais par un préfixe inline `VITE_TRANSPORT=http vite build`** : non portable Windows/NSIS, cible active du bundle. `.env.web` est versionné (non gitignoré) ; `dist-web/` est ignoré.
**Anti-divergence de `__IDEA_TRANSPORT__`** : `frontend/src/app/transport.ts` expose `transportFromEnv(value)`, importé à la fois par `di.tsx` (`resolveTransport`/`shouldUseHttp`) et par `vite.config.ts`, qui lui passe `VITE_TRANSPORT` via `loadEnv(mode)`. Même variable + même prédicat = une seule source. Modifier le prédicat déplace constante et runtime ensemble. `vitest.config.ts` réplique le `define` via le même helper.
La constante est **publiée sur `window` dans `main.tsx`** : `define` ne remplace que les occurrences existantes et une constante non référencée serait tree-shakée ; l'affectation est un effet de bord qui survit à la minification. Elle reporte le transport hors mock (`VITE_USE_MOCK` reste un switch distinct).
Pour identifier le transport d'un bundle bâti : `grep -o '__IDEA_TRANSPORT__="[a-z]*"' dist*/assets/*.js`. **Ne pas se fier à la présence de `__TAURI_INTERNALS__` ni à un nom de symbole minifié** : les deux jeux d'adapters sont présents dans les deux bundles, un grep ne les discrimine pas.

View File

@ -0,0 +1,44 @@
---
name: web-client-is-single-column-no-desktop-shell
description: memory note web-client-is-single-column-no-desktop-shell
metadata:
type: project
---
# Le client web n'a pas de shell desktop (ni docks, ni layout grid)
Constat établi en livrant le ticket #69 (adaptibilité téléphone), sur `develop @ 506d589`.
## Le fait
Le client web livré par #13 (`VITE_TRANSPORT=http`) **n'a jamais eu** de docks
gauche/droite, de fenêtres flottantes, de `LayoutTree`, de tabs projet, de menus ni
de toasts. `main.tsx` route `isWeb` vers `WebApp`, qui est une surface autonome :
- `WebApp``PairingScreen` **ou** `WebWorkspace` (rien d'autre) ;
- `WebWorkspace` → une **colonne verticale unique** (`flex flex-col overflow-y-auto`) ;
- `WebAgentCell``TerminalView`.
Les seuls imports `@/features/*` de tout `features/web/` sont `terminals` et
`workstate/useProjectWorkState`. Le shell desktop (`App`, layout, docks) n'est
atteignable que par la branche non-web de `main.tsx`.
## Pourquoi ça compte
Le cadrage de #69 prévoyait un lot « remplacer les docks gauche/droite/floating par
une pile verticale, drawer ou bottom sheet ». Ce lot était **sans objet** : il n'y a
pas de dock à remplacer, et l'arbitrage produit « bottom sheet vs drawer » ne se pose
pas sur cette surface. Le vrai travail téléphone était ailleurs (terminal xterm
pilotable au doigt, `dvh`, safe-area, rangées qui débordent à 360px).
**Avant de cadrer un ticket « responsive / mobile » sur le client web, partir de
`features/web/`, pas du shell desktop.** Les deux surfaces ne partagent que
`TerminalView` et les hooks transport-neutres.
## Garde en place
`frontend/src/features/web/WebMobile.test.tsx` épingle la frontière : le client web
ne doit monter aucun `layout-grid` / `layout-grid-container` / `layout-split` /
`layout-tabs`. Si un jour on veut du desktop-shell dans le web, ce test tombe — et
c'est le signal qu'il faut re-cadrer, pas le supprimer.
Voir aussi [[ui-rework-sprint-scoping-contracts]].

View File

@ -1,8 +1,8 @@
--- ---
issueRef: "#13" issueRef: "#13"
version: 18 version: 19
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784184002362 updatedAt: 1784193634690
--- ---
# Ticket #13 — Server/client mode (carnet de chantier) # Ticket #13 — Server/client mode (carnet de chantier)

View File

@ -2,7 +2,7 @@
id: "036783fa-9856-4643-8c28-653b93ac36e1" id: "036783fa-9856-4643-8c28-653b93ac36e1"
number: 13 number: 13
title: "Server/client mode" title: "Server/client mode"
status: "inProgress" status: "closed"
priority: "low" priority: "low"
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6" sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
links: [] links: []
@ -10,7 +10,7 @@ agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}
createdBy: {"kind":"user"} createdBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1783184188272 createdAt: 1783184188272
updatedAt: 1784184002362 updatedAt: 1784193634690
version: 18 version: 19
--- ---
J'aimerais pouvoir utiliser IdeA sous forme de client/server. C'est a dire que IdeA aurait son backend sur une machine et son interface qui serait un frontend web. Les agents etc seraient tous côté server. C'est a dire que la cli de Claude serait celle côté serveur, l'interface client ne serait que l'affichage frontend, une UI. Je pense que tout est faisable, il faudrait cependant parler du côté CLI. Est ce qu'il serait possible de garde rle CLI natif comme il est actuellement, de l'afficher côté client tout en gardant l'installation claude code, codex etc côté serveur ? Peut etre qu'en ssh ça passerait ? Ce ticket ne pourra pas être fait en autonomie par un agent sans une review agent-utilisateur avant. J'aimerais pouvoir utiliser IdeA sous forme de client/server. C'est a dire que IdeA aurait son backend sur une machine et son interface qui serait un frontend web. Les agents etc seraient tous côté server. C'est a dire que la cli de Claude serait celle côté serveur, l'interface client ne serait que l'affichage frontend, une UI. Je pense que tout est faisable, il faudrait cependant parler du côté CLI. Est ce qu'il serait possible de garde rle CLI natif comme il est actuellement, de l'afficher côté client tout en gardant l'installation claude code, codex etc côté serveur ? Peut etre qu'en ssh ça passerait ? Ce ticket ne pourra pas être fait en autonomie par un agent sans une review agent-utilisateur avant.

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#60" issueRef: "#60"
version: 2 version: 3
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"user"}
updatedAt: 1784095176619 updatedAt: 1784194089605
--- ---

View File

@ -4,14 +4,14 @@ number: 60
title: "[Bug] Assistant IA d'édition de ticket : aucune réponse dans la conversation (stream sans Final non signalé)" title: "[Bug] Assistant IA d'édition de ticket : aucune réponse dans la conversation (stream sans Final non signalé)"
status: "inProgress" status: "inProgress"
priority: "high" priority: "high"
sprint: null sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
links: [{"target":"#25","kind":"relatesTo"},{"target":"#27","kind":"relatesTo"}] links: [{"target":"#25","kind":"relatesTo"},{"target":"#27","kind":"relatesTo"}]
agentRefs: [] agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"user"}
createdAt: 1784094260499 createdAt: 1784094260499
updatedAt: 1784095176619 updatedAt: 1784194089605
version: 2 version: 3
--- ---
Rapporté par l'utilisateur (2026-07-15) : dans l'édition d'un ticket, le bouton « Assistant IA » ouvre une conversation, mais l'envoi d'un message ne produit JAMAIS de réponse visible — la conversation reste muette. PROFIL UTILISÉ : modèle local OpenAI-compatible (llamacpp). Rapporté par l'utilisateur (2026-07-15) : dans l'édition d'un ticket, le bouton « Assistant IA » ouvre une conversation, mais l'envoi d'un message ne produit JAMAIS de réponse visible — la conversation reste muette. PROFIL UTILISÉ : modèle local OpenAI-compatible (llamacpp).

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#64" issueRef: "#64"
version: 2 version: 5
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784187610934 updatedAt: 1784193766706
--- ---

View File

@ -4,14 +4,14 @@ number: 64
title: "Web : créer/ajouter un projet depuis le navigateur (folder browser serveur)" title: "Web : créer/ajouter un projet depuis le navigateur (folder browser serveur)"
status: "open" status: "open"
priority: "medium" priority: "medium"
sprint: null sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
links: [{"target":"#13","kind":"dependsOn"},{"target":"#66","kind":"dependsOn"}] links: [{"target":"#13","kind":"dependsOn"}]
agentRefs: [] agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784183727404 createdAt: 1784183727404
updatedAt: 1784187610934 updatedAt: 1784193766706
version: 2 version: 5
--- ---
Issu de la validation live de #13 (mode client/serveur web). En web, le bouton « Browse » (choisir un dossier de projet) est inopérant : `pickFolder()` renvoie `UNSUPPORTED_ON_WEB` car il s'appuie sur le dialogue natif OS de Tauri, absent du navigateur. Sélectionner un projet EXISTANT est déjà couvert par la liste (list_projects/open_project). Il manque CRÉER/AJOUTER un projet = choisir un dossier côté SERVEUR. Issu de la validation live de #13 (mode client/serveur web). En web, le bouton « Browse » (choisir un dossier de projet) est inopérant : `pickFolder()` renvoie `UNSUPPORTED_ON_WEB` car il s'appuie sur le dialogue natif OS de Tauri, absent du navigateur. Sélectionner un projet EXISTANT est déjà couvert par la liste (list_projects/open_project). Il manque CRÉER/AJOUTER un projet = choisir un dossier côté SERVEUR.

View File

@ -1,6 +1,46 @@
--- ---
issueRef: "#65" issueRef: "#65"
version: 1 version: 7
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784187583229 updatedAt: 1784210420516
--- ---
# Ticket #65 — Serveur headless `idea-serve` (carnet de chantier)
Statut : **QA — en attente de validation live utilisateur (non-régression desktop)**.
Branche : `feature/ticket65-idea-serve-headless` (base `develop` @ `506d589`). Local, aucune action sortante.
## Arbitrages Architect (tranchés)
- **DTO/events → propriétaire canonique = crate `backend`.** PAS de crate `presentation-dto`/`backend-api` : `backend` est déjà le composition root transport-neutre et la façade des use cases pour adapters entrants. `web-server` ET `app-tauri` le consomment ⇒ contrat canonique partagé entre deux driving adapters.
- **Frontière** : les DTO dans `backend` ne tirent NI Tauri, NI HTTP/WS/cookies, NI Axum/Hyper, NI UI. `domain`/`application` ne dépendent PAS des DTO. Vérifié QA.
- **Dédup events = dans L1, pas en dette** (sinon deux sources de vérité au moment où #65 stabilise le contrat). Dette acceptable uniquement pour le Tauri-spécifique (relay `app_handle.emit`, wiring runtime).
## Structure cible (livrée)
- `crates/backend/src/dto.rs` + `crates/backend/src/events.rs` ; `lib.rs` expose `pub mod dto; pub mod events;`
- `app-tauri/src/dto.rs` = shim `pub use backend::dto::*;`
- `app-tauri/src/events.rs` = relay Tauri uniquement, DTO importés de `backend::events`.
- `web-server` consomme `backend::{dto, events}`.
## Livré — L1/L2/L3 + run_embedded
Commits : `82e8e77` (wip de sûreté, ne fige rien) · `ddbea7b` (dédup DTO events).
`web_server::run_embedded(...)` présent : `ServerConfig`, `EmbeddedServerHandle`, `url()`, `pairing_code()`, `state()`, `stop()`, shutdown via `oneshot`. **C'est le point d'extension qui prépare #68** (le desktop consommera `web-server` comme lib, pas de fork).
## Vérif QA (exécution réelle) — VERT
- `cargo build -p web-server --bin idea-serve` + `-p app-tauri --bin app-tauri` : OK.
- `cargo test -p backend` 41 passed · `-p web-server` 55 passed · `-p app-tauri` 35 passed.
- **Invariant clé** : `ldd target/debug/idea-serve | rg -i 'webkit|javascriptcore|gtk|gdk|soup|tauri|wry'`**aucune sortie**. Comparatif `app-tauri` tire bien libwebkit2gtk/libgtk/libsoup/libjavascriptcore.
- Contrat HTTP/WS inchangé (55 tests web-server : pair/invoke/logout/ws/static/allowlist/B8). `git diff --name-only 506d589...HEAD -- frontend`**vide**.
## Faux mystère élucidé (à ne pas refaire paniquer)
`cargo test -p app-tauri` est passé de **45 → 35** : les tests ne sont PAS perdus, ils ont MIGRÉ. Écart réel depuis develop = **68 tests déplacés** (`src/lib.rs` : 104 → 36) : 55 `server::tests::*``web-server`, 10 `events::tests::*``backend::events`, 3 `dto::ls6_pagination_tests::*``backend::dto`. Vérifié par `cargo test -p app-tauri -- --list` comparé sur `506d589`.
## Faux négatif connu (arbitré NON bloquant)
`cargo test --workspace` : 10 échecs `infrastructure::session::openai_compat::*` + `factory_routes_openai_compatible_to_http_session` sur `bind: Os { code: 1, kind: PermissionDenied }` = la sandbox interdit l'écoute réseau. **Confirmé préexistant sur `506d589`** (même liste). Validation retenue par crates ciblés.
## Réserve QA restante
Aucun test dédié à `run_embedded().stop()` (un test de bind réel serait soumis à la même restriction sandbox). À couvrir dans #68, qui consommera ce handle.
## Incident de topologie (résolu — leçon)
DevBackend a d'abord travaillé sur la branche de #69 sans committer. Git a rapatrié le diff Rust (stash `-u` limité à `Cargo.lock Cargo.toml crates/`), intégrité vérifiée par empreinte SHA-256 avant/après (`c1aecea8…c499d3b` / `701bc7ee…4d6ff016`), rien perdu. **Leçon : vérifier la branche courante AVANT de commencer.**
## Prochaine étape
Validation live utilisateur : (1) le desktop ne perd rien, (2) `idea-serve` sert le client web. Si OK → Git merge dans develop (Git refuse de merger sans QA réelle sur les DEUX binaires ; point sensible = workspace `Cargo.toml` members). Débloque ensuite #68 (via `run_embedded`) et #66 (Docker).

View File

@ -2,16 +2,16 @@
id: "f9f7067b-92b7-4737-ad99-4487ad8c8b13" id: "f9f7067b-92b7-4737-ad99-4487ad8c8b13"
number: 65 number: 65
title: "Serveur headless : extraire idea-serve du binaire Tauri (cœur partagé)" title: "Serveur headless : extraire idea-serve du binaire Tauri (cœur partagé)"
status: "open" status: "closed"
priority: "high" priority: "high"
sprint: null sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
links: [{"target":"#13","kind":"dependsOn"}] links: [{"target":"#13","kind":"dependsOn"}]
agentRefs: [] agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784187583229 createdAt: 1784187583229
updatedAt: 1784187583229 updatedAt: 1784210420516
version: 1 version: 7
--- ---
Objectif : produire un binaire serveur headless (`idea-serve`) SANS dépendance Tauri/WebKit, bâti sur le même cœur backend que le desktop (pas de fork). Prérequis de l'image Docker serveur/client. Cadré par Architect (suite #13). Objectif : produire un binaire serveur headless (`idea-serve`) SANS dépendance Tauri/WebKit, bâti sur le même cœur backend que le desktop (pas de fork). Prérequis de l'image Docker serveur/client. Cadré par Architect (suite #13).

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#66" issueRef: "#66"
version: 1 version: 2
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"user"}
updatedAt: 1784187597004 updatedAt: 1784193460805
--- ---

View File

@ -4,14 +4,14 @@ number: 66
title: "Image Docker serveur/client IdeA" title: "Image Docker serveur/client IdeA"
status: "open" status: "open"
priority: "medium" priority: "medium"
sprint: null sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
links: [{"target":"#65","kind":"dependsOn"}] links: [{"target":"#65","kind":"dependsOn"}]
agentRefs: [] agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"user"}
createdAt: 1784187597004 createdAt: 1784187597004
updatedAt: 1784187597004 updatedAt: 1784193460805
version: 1 version: 2
--- ---
Objectif : livrer une image Docker exécutant le mode serveur/client d'IdeA, bâtie sur le binaire headless `idea-serve` (#65), pas sur le binaire Tauri. Cadré par Architect (suite #13). Objectif : livrer une image Docker exécutant le mode serveur/client d'IdeA, bâtie sur le binaire headless `idea-serve` (#65), pas sur le binaire Tauri. Cadré par Architect (suite #13).

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#67" issueRef: "#67"
version: 1 version: 2
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"user"}
updatedAt: 1784187618710 updatedAt: 1784193467784
--- ---

View File

@ -4,14 +4,14 @@ number: 67
title: "Lock inter-process de l'app-data-dir (desktop ↔ idea --serve)" title: "Lock inter-process de l'app-data-dir (desktop ↔ idea --serve)"
status: "open" status: "open"
priority: "low" priority: "low"
sprint: null sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
links: [{"target":"#13","kind":"dependsOn"}] links: [{"target":"#13","kind":"dependsOn"}]
agentRefs: [] agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"user"}
createdAt: 1784187618710 createdAt: 1784187618710
updatedAt: 1784187618710 updatedAt: 1784193467784
version: 1 version: 2
--- ---
Issu de la validation live #13. `idea --serve` et l'app desktop peuvent écrire le même app-data-dir (`~/.local/share/app.idea.ide`) simultanément — pas de lock inter-process → risque de corruption d'état (projects.json/profiles.json/tasks). Aujourd'hui contourné par une simple consigne de doc (« ne pas lancer les deux en même temps »). Issu de la validation live #13. `idea --serve` et l'app desktop peuvent écrire le même app-data-dir (`~/.local/share/app.idea.ide`) simultanément — pas de lock inter-process → risque de corruption d'état (projects.json/profiles.json/tasks). Aujourd'hui contourné par une simple consigne de doc (« ne pas lancer les deux en même temps »).

View File

@ -0,0 +1,63 @@
---
issueRef: "#68"
version: 8
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784277313706
---
# Ticket #68 — Serveur embarqué activable depuis le desktop : carnet de chantier
Statut : **livré et mergé dans `develop` (`f965f64`)** le 2026-07-16, en exécution autonome de nuit.
**Reste à faire : la validation live utilisateur.** Rien n'a été cliqué dans l'app réelle — voir la réserve plus bas.
## Livraison en deux temps
**B1 — seam shared-core** (mergé plus tôt, `b7d38f1` + `328c941`). `web_server::run_embedded_with_core(config, Arc<BackendCore>)`. Sans lui, le desktop aurait construit une **seconde composition root dans le même processus** — deux event bus, deux registres live, deux jeux de stores — et le conflit d'écriture app-data-dir serait réapparu à l'intérieur d'un seul process.
**B2/F1/F2 — le reste** (`49e1d5a` backend, `7efa634` frontend, merge `b30c9c7`).
- `crates/app-tauri/src/embedded_server.rs` : controller de cycle de vie sur `AppState`, commandes `embedded_server_start/stop/status` et `get/save/preview_server_exposure_settings`, `FsServerExposureSettingsStore`.
- `frontend/src/features/settings/` : `SettingsView`, `DeploymentSettings`, `useDeployment` ; `adapters/desktopServer.ts` ; port `DesktopServerGateway`.
- `ProjectsView` : `showSettings: boolean` → navigation interne `AI Profiles` / `Deployment`. Le libellé alternant « Close AI Profiles » a disparu.
## Décisions structurantes appliquées
- **Pas de `PanelId: "settings"`.** Architect s'est corrigé en cours de route : Settings existe déjà dans `ProjectsView` comme surface principale, hors modèle `viewPlacement`. C'est de la configuration globale persistée, pas une vue projet dockable/détachable.
- **Persistance dans `<app-data-dir>/deployment/server-exposure.json`**, pas `localStorage`/`uiPreferences` : falsifier cette config peut exposer le serveur, c'est de la config de sécurité. Écriture atomique, `0600` posé **avant** le `rename`.
- **Le pairing code n'est jamais persisté** — secret runtime, affiché seulement si `running`.
- **Le frontend n'invente jamais une IP** : `candidateLanAddresses` et `upstreamUrl` viennent du backend, d'où l'existence de `preview`.
- `EmbedderSettings` / `ModelServersPanel` **non rapatriés** : refonte latérale hors sprint. La liste de sections est le seam où ils s'inséreront.
## ⚠️ Le brief de Main était FAUX — intercepté par DevFrontend
Main décrivait le mode `remoteProxyOtherMachine` avec deux champs (origine publique + IP du proxy). **`validate_settings` en exige un troisième : `lanBindAddress`**, et rejette loopback/unspecified. Construit selon le brief, le lot aurait été **vert et cassé** : chaque `save` et chaque `start` en mode 3 auraient échoué, sans qu'aucun test ne le voie.
Corrigé par un select alimenté par `candidateLanAddresses` — la règle « le frontend n'invente jamais une IP » tient. Leçon consignée dans `7efa634`.
## Vérification réelle (Main puis Git, tous deux hors sandbox)
```
npm run typecheck → propre
npx vitest run → 87 files / 789 passed
cargo test -p app-tauri → 43 passed, 1 ignored
cargo test -p web-server → 66 passed
```
Re-vérifié sur `develop` après merge : 24 suites Rust, 0 échec.
**Invariants vérifiés par exécution, pas par lecture de rapport :** `run_embedded_with_core` est le seul appelant (aucun `run_embedded` nu dans `app-tauri`) ; aucune fuite d'`EmbeddedServer*`/`ServerExposure*` dans `domain` ni `application` ; ordre du store `write(tmp)``chmod 0600``rename` ; `stop()` appelé à la sortie d'app (`lib.rs:153`) ; `features/web` n'importe pas la surface Settings (épinglé par test) et le transport web rend `UNSUPPORTED_ON_WEB`.
**Piège d'environnement :** `app-tauri` et `web-server` ont un sandbox qui bloque `TcpListener::bind` (EPERM). Le test `end_to_end_over_real_loopback` est `#[ignore]` **préexistant** — lancé hors sandbox avec `--ignored`, il passe. Garde d'environnement confirmée par exécution, pas faux vert.
## RÉSERVE — aucune validation live
Tout est vert, **rien n'a été cliqué**. Le panneau n'a jamais été ouvert dans l'app réelle : ni le démarrage/arrêt, ni l'affichage du pairing code, ni les trois modes, ni la persistance entre deux lancements. jsdom ne prouve pas le rendu. Une AppImage a été construite pour que l'utilisateur teste — c'est le point de vérité qui manque.
## Dette identifiée, non traitée (ne bloque rien)
1. **`preview` valide avant de prévisualiser** (`embedded_server.rs:211`) : un brouillon mode 3 ne peut pas être prévisualisé tant qu'il n'a pas le `lanBindAddress` que la liste des candidats doit justement fournir. Contourné côté UI par une sonde `localOnly` toujours valide. Défaut d'ordonnancement backend.
2. **Code mort** : le warning `missingTrustedProxy` (`embedded_server.rs:452`) est inatteignable — `validate_settings` rejette les `trustedProxies` vides en mode 3 avant que `preview_settings` ne puisse le produire.
3. **Aucun événement de statut** : le backend n'émet rien, `onStatusChanged` est du polling **dans l'adapter Tauri**. La forme du port est prête pour un vrai événement, sans toucher l'UI.
4. **Fenêtre umask sur le temporaire** du store : le `0600` arrive après la création. Aucun secret dans ce fichier (modes, ports, IP), risque faible. Un `OpenOptions::mode(0o600)` à la création fermerait le sujet.
5. **Piège latent hors #68** : les stubs `unsupported.ts` préexistants (`WebRemoteGateway`, `WebWindowGateway`) *throwent de façon synchrone* depuis des méthodes qui rendent des Promise — `.then(ok, err)` ne les attraperait pas. Sans effet aujourd'hui (les appelants `await`), mais mérite un nettoyage.
## Contexte : pourquoi #68 est passé avant #71 et #73
Arbitrage **utilisateur**, contre la recommandation d'Architect. Ce dernier proposait #72#73#71#68, ce qui reléguait en dernier la fonctionnalité demandée le matin même. L'utilisateur a tranché : #72 ajusté, puis #68. #71 (diagnostics) et #73 (TLS intégré) suivent.

View File

@ -0,0 +1,16 @@
---
id: "13821b24-f566-402e-bc10-02ac6e8a5f7c"
number: 68
title: "Ajouter dans l'app desktop, la possibilité d'activer le serveur"
status: "closed"
priority: "medium"
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
links: [{"target":"#65","kind":"dependsOn"}]
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784193318419
updatedAt: 1784277313706
version: 8
---
J'iamerais que depuis l'app desktop, on puisse quand même embarquer le serveur. Comme ça un utilisateur pourrait continuer son travail en cours en remote

View File

@ -0,0 +1,56 @@
---
issueRef: "#69"
version: 11
updatedBy: {"kind":"agent","agent_id":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"}
updatedAt: 1784207736267
---
# Ticket #69 — Adaptibilité client téléphone (carnet de chantier)
Statut : **validation live utilisateur PARTIELLE effectuée — mergé dans `develop` avec réserve explicite sur le lot 3**.
Branche : `feature/ticket69-mobile-responsive-client`, **rebasée sur `develop` @ `fe0e53e`** (base d'origine `506d589`). Commits après rebase : `48b8853` (shell), `62ecefe` (terminal + tests), `f0f2f3b` (fix typecheck) — anciennement `e22ea5b`/`8f15ad6`/`6ed0087`. Arbre `frontend/` identique bit pour bit avant/après rebase (`git diff 6ed0087 HEAD -- frontend` vide) ; historique d'origine conservé sur `backup/ticket69-pre-rebase-6ed0087`. Frontend uniquement — vérifié.
## Cadrage Architect
Ticket **frontend-pur** : aucun changement DTO, use case, ni modèle `LayoutTree`. Contrats HTTP/WS et layout/terminal inchangés. Mais PAS du « CSS pur ».
## ⚠️ Le cadrage initial reposait sur un modèle FAUX (constat majeur)
Le lot 2 prévoyait « remplacer les docks gauche/droite/floating par une pile verticale/drawer/bottom sheet ». **Il n'y a rien à remplacer** : le client web n'a JAMAIS eu de docks, de fenêtres flottantes ni de `LayoutTree`. `main.tsx` route le mode `http` vers `WebApp`, surface autonome livrée par #13 : pairing **ou** `WebWorkspace`, déjà une colonne verticale unique. Les seuls imports `@/features/*` de tout `features/web/` sont `terminals` et `workstate` — le shell desktop n'est pas atteignable depuis le web.
**La question « bottom sheet vs drawer » ne se pose pas.** Frontière épinglée par un test (`WebMobile.test.tsx`) + mémoire `web-client-is-single-column-no-desktop-shell`, pour que le cadrage ne reparte pas du même modèle faux.
## Le vrai blocage était le terminal, pas le layout
Le clavier virtuel n'a ni Esc, ni Tab, ni Ctrl, ni flèches : on pouvait taper un prompt à un agent CLI mais pas l'interrompre, compléter un chemin, sortir d'un éditeur ou rappeler l'historique.
## Livré
- **Lot 1 (shell)** : `100dvh` (le `height:100%` se résolvait sur le *large* viewport ⇒ la barre d'URL rétractable masquait le bas), `viewport-fit=cover` + safe-area insets, padding responsive, `inputMode="numeric"` sur le code d'appairage. Zoom laissé libre (accessibilité).
- **Lot 2 (réinterprété)** : `AgentLiveRow` + rangée de tâche de fond (statut/kind/Cancel/Retry ne tenaient pas à 360px) s'empilent sous `sm`, ligne unique dès `sm`.
- **Lot 3 (terminal)** : `TerminalView` expose `onReady(api)` **optionnel** (desktop inchangé) ; `api.send` passe par `term.input()` — même chemin qu'une frappe réelle, donc relais PTY + suspension du write-portal s'appliquent à l'identique (écrire sur le handle aurait court-circuité le portal). `TerminalKeyBar` : Esc/Tab/Ctrl-C/Ctrl-D/flèches en chips 44px ; **chaque tap annule le déplacement de focus** puis refocalise xterm (sinon le clavier virtuel se referme à chaque touche). Cellule `h-64``h-[60dvh]`.
## Vérif QA (exécution réelle) — VERT
- `npm run typecheck` exit 0 · `npx vitest run` → 85 files / **774 passed** · `VITE_TRANSPORT=http npx vite build` OK.
- **CSS mobile confirmé DANS le bundle** (les valeurs arbitraires Tailwind avec `env()` no-opent en silence) : `100dvh`, `60dvh`, les 4 `safe-area-inset-*`, `viewport-fit=cover`, `.h-\[60dvh\]{height:60dvh}`.
- Non-régression desktop : `LayoutGrid` ne passe pas `onReady` ; chemin desktop inchangé.
- Rendu réel Chrome headless 360×780 : pairing tient sans débordement horizontal.
- **Ré-exécutée par Git sur la base rebasée `fe0e53e` avant merge** (les chiffres du carnet ne valaient que sur `506d589`) : `npm run typecheck` exit 0 · `npx vitest run` → 85 files / **774 passed**, 0 échec · `VITE_TRANSPORT=http npx vite build` exit 0. Identique — le rebase n'a rien cassé.
## Réserve ASSUMÉE — trou de couverture réel
**jsdom n'évalue PAS les media queries** : les tests sont structurels, pas visuels. Ils ne prouvent NI le rendu aux breakpoints, NI l'effet réel de `dvh`, NI les safe areas, NI le clavier virtuel/focus sur navigateur mobile. QA : « acceptable pour merger avec réserve explicite », pas rouge. **C'est exactement ce que la validation utilisateur doit couvrir.**
## Validation live utilisateur — PÉRIMÈTRE EXACT (2026-07-16)
Effectuée par l'utilisateur sur un **vrai téléphone**, contre le serveur `idea-serve` exposé via le reverse proxy `https://idea.anthonybouteiller.ovh`.
**Couvert — verdict utilisateur : OK.** « Le terminal s'ouvre correctement pour le téléphone. » Atteindre le terminal implique d'avoir traversé l'appairage **puis** le workspace : cela valide en réel le shell (lot 1) et la mise en page à taille de téléphone (lot 2), y compris le `100dvh` et le `60dvh` de la cellule terminal, que jsdom ne pouvait pas prouver.
**NON couvert — angle mort qui subsiste.** Le **lot 3 n'a pas été exercé** : la `TerminalKeyBar` (Esc/Tab/Ctrl-C/Ctrl-D/flèches) n'a pas été utilisée, et le comportement de focus au tap — l'invariant « chaque tap annule le déplacement de focus puis refocalise xterm », sans lequel le clavier virtuel se referme à chaque touche — n'a **pas** été observé sur un navigateur mobile réel. Or c'est le cœur du ticket : le carnet identifie le terminal, pas le layout, comme le vrai blocage.
Le test qui fermerait ce trou tient en 30 s : lancer une commande longue, taper **Ctrl-C** dans la barre de touches, vérifier (a) que la commande s'interrompt et (b) que le clavier virtuel reste ouvert.
**Arbitrage Main : merge autorisé avec cette réserve écrite, pas effacée.** Le risque résiduel est borné (surface frontend-pure, desktop non atteint, non-régression prouvée) et ne justifie pas de bloquer le sprint. Mais tant que le test Ctrl-C n'est pas fait, **le lot 3 est livré et non prouvé en conditions réelles** — à ne pas présenter comme validé.
**Report de la réserve dans l'historique git (Git, 2026-07-16)** : la réserve est recopiée dans le corps du commit de merge de `develop`. Un carnet peut être réécrit ; un message de commit mergé, non. Le fait « lot 3 non prouvé en réel au moment du merge » est donc daté et infalsifiable, indépendamment de ce fichier.
## Écart de périmètre — Playwright : TRANCHÉ, reporté vers #63
Lot 4 demandait « Playwright ou équivalent ». DevFrontend a livré l'équivalent (vitest/jsdom) et REFUSÉ d'installer Playwright unilatéralement (binaires navigateur + surface CI = décision de dépendance qui remonte à Architect/Main) — refus fondé. QA : non bloquant pour ce lot.
**Décision Main (2026-07-16) : ne bloque pas #69 et ne donne PAS lieu à un ticket neuf.** La décision de dépendance appartient à **#63 « Système de test de l'UI »**, déjà ouvert, où elle sera tranchée avec la stratégie de test UI dans son ensemble plutôt qu'au coin d'un sprint serveur/client. Rationnel conservé : Playwright rend un vrai navigateur (media queries, `dvh`, safe areas évalués pour de vrai) et aurait attrapé automatiquement les deux bugs de classe « CSS silencieusement absent du bundle » corrigés ici ; il **n'émule** cependant qu'un téléphone et ne remplace pas la validation manuelle du clavier virtuel — sa valeur est d'empêcher la régression silencieuse entre deux validations manuelles.
## Prochaine étape
Merge fait par Git (rebase + `--no-ff` dans `develop`, sans conflit). **Le test Ctrl-C sur téléphone réel reste ouvert** : s'il échoue, il ouvre un bug, il ne réverte pas le ticket. Le passage du ticket à un statut terminal appartient à Main : tant que le lot 3 n'est pas prouvé en réel, « mergé » ≠ « validé ».

View File

@ -0,0 +1,16 @@
---
id: "7f5a40cf-837f-46f7-8c5f-653c80975396"
number: 69
title: "Adaptibilité client téléphone"
status: "qa"
priority: "medium"
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
links: []
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"}
createdAt: 1784193391258
updatedAt: 1784207736267
version: 11
---
J'aimerais un format vertical qui rende l'app client compatible téléphone

View File

@ -0,0 +1,6 @@
---
issueRef: "#70"
version: 4
updatedBy: {"kind":"user"}
updatedAt: 1784194049447
---

View File

@ -0,0 +1,16 @@
---
id: "e73ed25f-8e22-48a5-84ea-521212ea80b5"
number: 70
title: "Pouvoir gérer les models AI locaux téléchargés via les serveur llamacpp"
status: "open"
priority: "low"
sprint: null
links: []
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784194016529
updatedAt: 1784194049447
version: 4
---
Avec les serveurs de models locaux llamacpp, on télécharge des modeles, j'aimerais aussi pouvoir les supprimer

View File

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

View File

@ -0,0 +1,39 @@
---
id: "4d78a0e9-b54c-4db6-92e5-1461c17d7c84"
number: 71
title: "Diagnostic d'accessibilité du serveur : dire pourquoi ça ne marche pas, pas seulement que ça tourne"
status: "open"
priority: "medium"
sprint: null
links: [{"target":"#68","kind":"relatesTo"},{"target":"#65","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784210443885
updatedAt: 1784210443885
version: 1
---
Issu d'une session de debug live réelle (2026-07-16) où un serveur `idea-serve` CORRECTEMENT configuré est resté injoignable, sans que rien dans le produit n'indique pourquoi. Le serveur tournait, se croyait bon, affichait « listening on … ». Deux boucles de debug successives ont été nécessaires, dont aucune n'était diagnosticable depuis le produit.
**Problème de fond : l'échec est invisible.** Un serveur peut être parfaitement configuré et totalement inatteignable ; aujourd'hui la seule façon de le savoir est de sortir du produit (curl, ss, ufw, dig).
Les trois échecs réels rencontrés, tous silencieux :
1. **Bind loopback + reverse proxy sur une autre machine** → le proxy ouvre une connexion vers *sa propre* loopback, la requête n'arrive jamais. Aucun message, juste un timeout côté navigateur. Aggravé par `docs/server-client-mode-remote.md`, dont l'exemple du cas distant utilise `--listen 127.0.0.1:17373` en supposant sans le dire que le proxy est co-localisé (correction doc traitée à part).
2. **Pare-feu hôte (ufw actif, port non ouvert)** → dissymétrie caractéristique : joignable depuis la machine locale, timeout depuis toute autre machine. Rien ne le signale.
3. **Origine non conforme → 403 muet**`--public-origin` compare en ÉGALITÉ STRICTE. Ouvrir l'UI par l'IP (`http://192.168.1.75:17373`) au lieu du nom de domaine donne un 403 sur toute l'API, sans explication dans l'UI. C'est le piège n°1 pour un nouvel utilisateur.
**Périmètre proposé (à cadrer par Architect).**
**Lot 1 — Surfacer les origines rejetées. Meilleur rapport valeur/coût : l'événement EXISTE déjà.** `SecurityLogEvent::OriginRejected { origin, route }` dans `crates/web-server/src/lib.rs` est aujourd'hui simplement `eprintln!` sur stderr — donc invisible en desktop, et invisible en Docker sans aller lire les logs. L'exposer (DTO + surface UI) transforme le mystère en diagnostic d'une ligne : « un client s'est connecté depuis `http://192.168.1.75:17373`, or l'origine autorisée est `https://idea.anthonybouteiller.ovh` ». Attention : c'est un log de sécurité, borner la rétention et ne pas en faire un canal de fuite d'information.
**Lot 2 — Dire quand le serveur est JOIGNABLE, pas seulement qu'il tourne.** Le serveur ne peut pas se tester depuis l'extérieur, mais il peut détecter ce qui rend l'échec certain :
- pare-feu hôte actif dont le port d'écoute n'est pas autorisé (ufw/firewalld) ;
- `public_origin` dont le DNS ne résout vers aucune interface locale ⇒ indice fort que le proxy est ailleurs, donc qu'un bind loopback ne peut pas marcher ;
- afficher l'upstream exact à coller dans la config du proxy (`http://192.168.1.75:17373`) plutôt que de le laisser deviner.
Ces détections sont des heuristiques : elles doivent INFORMER, jamais bloquer le démarrage.
**Hors périmètre :** le choix du mode par intention (« proxy sur cette machine » vs « sur une autre machine ») appartient à F2 de #68 — c'est le panneau desktop, ça ne change aucun contrat backend.
**Attention frontière :** les lots ci-dessus doivent servir AUSSI le mode headless/Docker (`idea-serve` sans desktop), pas seulement le panneau desktop. Le diagnostic ne doit pas naître prisonnier de l'UI Tauri.

View File

@ -0,0 +1,55 @@
---
issueRef: "#72"
version: 3
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784236365445
---
# Ticket #72 — Confiance reverse proxy : carnet de chantier
Statut : **livré et mergé dans `develop` (`7fbaa8e`)** le 2026-07-16.
Commits : `75d1ce8` (code), `42804fc` (doc), `ae01297` (merge `--no-ff`). Branche supprimée.
## Ce qui a été livré
- `trusted_proxies` dans `ServerConfig` + flag CLI `--trusted-proxy` (répétable, IP ou CIDR).
- Refus **au démarrage** d'un bind non-loopback distant sans proxy autorisé.
- Guard runtime sur les **trois** surfaces — statique, `/api/*`, WebSocket : le `peer_addr` est vérifié **avant** toute lecture de header.
- `X-Forwarded-Proto: https` obligatoire en mode proxy.
- `X-Forwarded-For` n'autorise **jamais** rien — journalisé uniquement.
- **B0** : réconciliation du port effectif dans `run_server` (le chemin CLI standalone souffrait du même bug que l'embedded : `origin_allowed` comparait à `http://127.0.0.1:0`).
- Diagnostics : `UntrustedProxyPeer`, `ForwardedProtoRejected`, `ForwardedHostMismatch`.
## Ajustement en cours de route — le hard reject `X-Forwarded-Host` a été RETIRÉ
Cadré initialement comme un refus, il est devenu un **warning de diagnostic**. Raison (Architect) : redondant avec la vérification d'`Origin` en égalité stricte déjà appliquée à toutes les routes `/api/*`, et il **cassait la configuration par défaut de nginx**, qui n'envoie pas cet en-tête. Le vrai contrôle d'accès reste pairing/session + pair proxy autorisé. Gain trop faible pour la friction créée.
Le repli de lecture accepte `X-Forwarded-Host`, `Forwarded host=` **ou** `Host`.
## Vérification réelle (Main, hors sandbox)
`cargo test -p web-server`**66 passed / 0 failed**. Re-vérifié sur `develop` après merge : 66 · 41 (backend) · 35 (app-tauri), 0 échec.
**Comportement exercé sur un serveur réellement lancé**, pas seulement en test :
- proxy autorisé + `X-Forwarded-Proto: https` + **host absent** (= nginx par défaut) → **passe** le guard, `forwardedHostMismatch` en diagnostic.
- host **différent** → passe également, warning seulement.
- `X-Forwarded-Proto` absent → **403**, message : « Configure the reverse proxy to send it; for nginx add: proxy_set_header X-Forwarded-Proto $scheme; ».
## ⚠️ Piège d'environnement — un faux vert s'est produit ici, deux fois
**DevBackend et QA sont tous deux bloqués par EPERM sur `TcpListener::bind`.** Un `cargo test -p web-server` sandboxé rend un vert qui ne prouve rien : sur #68 B1, un « 57 passed » sandboxé a masqué un **vrai bug produit** (port éphémère vs `origin_allowed`). Tout vert sur ce crate doit être produit **hors sandbox**. C'est consigné dans les messages de commit, pas seulement ici.
## Revue de sécurité (Git, au-delà du vert)
Guard câblé sur les trois surfaces, aucune route qui y échappe. Le chemin de production propage le vrai pair (`handle_tcp_connection``Some(peer_addr.ip())`). Le repli `peer_ip.unwrap_or(config.listen.ip())` n'est atteignable que depuis des appelants `#[cfg(test)]`. CIDR correct, y compris `prefix == 0` (qui aurait débordé au shift sans traitement à part).
## Changement de comportement ASSUMÉ
Le durcissement **casse volontairement les configurations existantes** : un bind non-loopback distant sans `--trusted-proxy` est désormais refusé au démarrage, avec un message qui dit quoi faire. Consigné dans l'historique git.
## Origine du ticket
Trouvé par **Git**, pas par une revue d'architecture : c'est lui qui a établi que `--trust-reverse-proxy` n'avait aucun effet runtime — il n'était lu que pour exiger sa propre présence. La trilogie déclarait une topologie sécurisée sans jamais la garantir.
## Point ouvert, remonté par Git — à arbitrer par l'utilisateur
**#73 (TLS intégré) rendrait une partie de #72 obsolète.** Si `idea-serve` termine lui-même TLS, la cérémonie proxy — `--trusted-proxy`, le guard, ces diagnostics — ne concerne plus que les déploiements qui gardent un proxy devant. Ce n'est pas du travail perdu (#72 corrige un vrai trou aujourd'hui, et B0 était un bug réel), mais l'ordre des deux mérite un regard.

View File

@ -0,0 +1,53 @@
---
id: "0fba0a7f-8f04-460e-b81a-93790e10b066"
number: 72
title: "Sécurité : donner un effet réel à la confiance reverse proxy (--trust-reverse-proxy est un drapeau creux)"
status: "closed"
priority: "high"
sprint: null
links: [{"target":"#68","kind":"blocks"},{"target":"#71","kind":"relatesTo"},{"target":"#65","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784218596269
updatedAt: 1784236365445
version: 3
---
**Vulnérabilité de conception existante, dans le mode CLI d'aujourd'hui — indépendante de #68.** Trouvée par Git pendant la mise en place de #68, confirmée et cadrée par Architect.
**Le constat.** `--trust-reverse-proxy` n'a **aucun effet à l'exécution**. Vérifié dans `crates/web-server/src/lib.rs` : le champ n'est lu qu'en ligne 192, dans `ServerConfig::validate()`, **pour exiger sa propre présence** quand `allow_remote` est vrai. Le serveur ne parse jamais `X-Forwarded-*`. C'est un drapeau d'intention pur.
**Le problème.** `validate()` valide une configuration, pas la réalité : rien ne vérifie qu'un proxy est réellement devant le serveur. **Binder `0.0.0.0` avec les trois drapeaux, sans aucun proxy, satisfait `validate()` et sert du HTTP en clair au monde entier.** La trilogie `allow_remote` + origine HTTPS + `trust_reverse_proxy` DÉCLARE une topologie sécurisée, elle ne la GARANTIT pas.
Verdict Architect : « la trilogie actuelle n'est pas un invariant de sécurité, c'est seulement une déclaration d'intention ». Aggravé par #68 : en CLI, taper trois drapeaux est un acte délibéré d'opérateur ; dans un panneau desktop, cocher un bouton ne l'est pas. Le drapeau donnerait un faux sentiment de protection en un clic.
**Invariant runtime corrigé (Architect).**
- `allow_remote=true` signifie : mode public accepté UNIQUEMENT derrière un proxy HTTPS déclaré.
- Les headers `X-Forwarded-*` ne sont fiables QUE si le `peer_addr` réseau est un proxy autorisé — vérifier le pair AVANT de leur faire confiance.
- `X-Forwarded-Proto: https` exigé en mode remote.
- `X-Forwarded-Host` (ou équivalent) doit correspondre à `public_origin`.
- **`X-Forwarded-For` ne doit JAMAIS servir à autoriser une requête** — spoofable, log uniquement.
- Proxy sur cette machine : bind loopback, peer forcément loopback, `trust_reverse_proxy` reste acceptable.
- Proxy sur une autre machine : bind LAN autorisé SEULEMENT avec un proxy explicite (`--trusted-proxy 192.168.1.10` ou CIDR). Toute requête dont le `peer_addr` est hors allowlist est refusée, même porteuse de `X-Forwarded-Proto: https`.
Conséquence produit : le mode desktop « proxy autre machine » **ne peut pas être un clic magique**. Il doit demander l'IP/CIDR du proxy autorisé — ce n'est pas une « listen address » mais un contrôle de sécurité compréhensible : « quelle machine a le droit de parler au serveur IdeA ? ».
**Lots (Architect).**
- **B1 config** : `trusted_proxies: Vec<IpNet/Cidr>` dans `ServerConfig`, flag CLI `--trusted-proxy`, DTO desktop équivalent.
- **B2 runtime guard** : en tête du handling HTTP/WS, vérifier `peer_addr`, `X-Forwarded-Proto=https`, host public conforme.
- **B3 diagnostics** : événements `untrustedProxyPeer`, `forwardedProtoRejected`, `forwardedHostRejected` (à croiser avec #71).
- **B4 docs** : exemples séparés proxy local vs proxy autre machine.
**Points de vérité QA.**
- `0.0.0.0 + allow_remote + public_origin + trust_reverse_proxy` sans trusted proxy → REFUSÉ.
- peer non autorisé vers bind LAN → rejeté.
- peer autorisé mais `X-Forwarded-Proto=http` → rejeté.
- peer autorisé + https + host conforme → passe.
- origine API non conforme → toujours 403.
- aucun test ne fait confiance à `X-Forwarded-For`.
**Compat / migration.** Garder `--trust-reverse-proxy` pour la CLI, mais le déclasser en option de mode : il ne suffit plus pour un bind non-loopback. Chemin de migration à choisir (refus au démarrage vs refus runtime).
⚠️ **Impact utilisateur réel connu** : l'installation actuelle de l'utilisateur (bind `192.168.1.75:17373` + trilogie, proxy sur une autre machine, exposé via `https://idea.anthonybouteiller.ovh`) sera **refusée** par la validation corrigée tant qu'elle ne déclare pas `--trusted-proxy`. Le chemin de migration doit être explicite et le message d'erreur doit dire quoi faire, pas seulement refuser.
**Ordre imposé par Architect** : #68 core (B1 shared-core) peut continuer, il n'est pas exposé utilisateur. Mais **#72 doit être mergé dans `develop` AVANT `feature/ticket68-embedded-server-panel`** — pas de panneau remote avant ce durcissement.

View File

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

View File

@ -0,0 +1,46 @@
---
id: "a43a07d0-573f-41e9-9b7b-2f88beb4e660"
number: 73
title: "TLS intégré à idea-serve : supprimer la cause racine de la cérémonie reverse proxy"
status: "open"
priority: "high"
sprint: null
links: [{"target":"#72","kind":"relatesTo"},{"target":"#66","kind":"relatesTo"},{"target":"#68","kind":"relatesTo"},{"target":"#71","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784223044517
updatedAt: 1784223044517
version: 1
---
**Décision utilisateur (2026-07-16) : IdeA gagne le TLS intégré.** Contrainte posée : prendre la technologie la plus légère.
**La cause racine, établie par une session de debug réelle.** Un utilisateur a mis une matinée à exposer `idea-serve` derrière son nginx. Cinq obstacles, découverts un par un en tapant dans le mur : bind loopback injoignable par un proxy distant (timeout muet), ufw (timeout muet), `--trusted-proxy` obligatoire (#72), `X-Forwarded-Proto` absent (403), `X-Forwarded-Host` absent (403).
**Ces cinq obstacles ont UNE cause : le serveur ne sait pas chiffrer.** Ne pouvant pas terminer le TLS, il doit *croire quelqu'un d'autre sur parole* quand on lui dit que la connexion publique était en HTTPS. `--trusted-proxy` sert à savoir qui a le droit de lui mentir ; `X-Forwarded-Proto` est le mensonge lui-même. Toute la machinerie de #72 n'existe **que** parce qu'il manque cette capacité. #66 avait acté l'exclusion noir sur blanc (« Pas de TLS applicatif V1 », « Hors périmètre V1 : TLS intégré ») — c'est cette ligne qui a produit les cinq obstacles.
**Ce que le TLS intégré change.** L'utilisateur ouvre le 443 de son routeur, donne son domaine, terminé : pas de `--trusted-proxy`, pas de `X-Forwarded-*`, pas de proxy du tout. Un reverse proxy devant reste possible, mais devient un **choix**, plus une obligation structurelle. Une bonne partie de #72 se simplifie mécaniquement — sans disparaître : le mode « derrière proxy » reste supporté et garde ses contrôles.
**Constat technique qui répond à la contrainte de légèreté (vérifié dans `Cargo.lock`) :**
- `rustls` 0.23, `tokio-rustls`, `ring`, `webpki-roots`, `hyper-rustls` sont **DÉJÀ** dans l'arbre de dépendances.
- **Aucun** `openssl`, `openssl-sys` ni `native-tls`.
- Le coût marginal d'un TLS serveur est donc quasi nul : la brique est déjà vendue avec le projet. Pas de nouvelle chaîne de dépendances, pas de dépendance système (contrairement à OpenSSL).
**Pistes ACME à arbitrer par Architect :**
- `rustls-acme` — bâti sur rustls/tokio, fournit le resolver de certificats et le renouvellement automatique. Plus intégré, moins de code à écrire.
- `instant-acme` — plus bas niveau, pure Rust, minimal ; le challenge et le stockage restent à notre charge.
- **Argument fort pour le challenge TLS-ALPN-01 : il ne nécessite QUE le port 443.** Pas de port 80, pas de web root séparé pour HTTP-01. Cohérent avec « l'utilisateur ouvre un port ».
**Limites à assumer et à documenter (ne pas vendre du rêve) :**
- ACME exige un **domaine public** et un port joignable depuis l'extérieur. Couvre l'exposition Internet, **pas** le LAN pur sans domaine.
- LAN pur ⇒ certificat auto-signé ⇒ avertissement navigateur. À traiter comme un mode distinct, pas à masquer.
- IdeA devient **responsable des certificats** : stockage, permissions, renouvellement, gestion des rate limits Let's Encrypt (utiliser l'environnement de staging en test, sous peine de blocage).
- Ne jamais mettre de certificat/clé dans un dossier synchronisé ou versionné.
**Interactions à cadrer :**
- **#72** : le mode « TLS direct » ne doit exiger ni `--trusted-proxy` ni `X-Forwarded-*` — le serveur SAIT que la connexion est HTTPS puisqu'il la termine. Le mode « derrière proxy » garde ses contrôles.
- **#66 (Docker)** : son défaut `--listen 0.0.0.0:17373` + distant est **déjà invalide** depuis #72 (refusé au démarrage sans `--trusted-proxy`). Le TLS intégré rouvre la question de l'image : proxy embarqué ou TLS applicatif ?
- **#68** : les modes d'exposition du panneau desktop gagnent un mode « TLS direct », et le champ « IP du proxy autorisé » disparaît dans ce mode.
- **#71** : les diagnostics doivent couvrir les échecs ACME (challenge, DNS, port 443 injoignable), qui seront la nouvelle classe d'échec silencieux.
**Exigence produit transverse, issue du même incident :** l'échec ne doit jamais être muet. Un certificat qui ne s'obtient pas doit le dire, avec la raison et l'action. C'est exactement le défaut qu'on corrige partout ailleurs dans ce sprint (#71).

View File

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

View File

@ -0,0 +1,67 @@
---
id: "249bb0de-118f-4d6f-b0c4-1d4e2aadd83f"
number: 74
title: "Le serveur embarqué sert le bundle desktop (Tauri) au navigateur — __TAURI_INTERNALS__ undefined"
status: "closed"
priority: "high"
sprint: null
links: [{"target":"#68","kind":"relatesTo"},{"target":"#66","kind":"relatesTo"},{"target":"#13","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784271086298
updatedAt: 1784277308704
version: 3
---
## Symptôme (constaté live, exposition derrière reverse proxy)
UI chargée dans le navigateur via `https://idea.anthonybouteiller.ovh` (nginx → 192.168.1.75:17373, serveur embarqué desktop, mode `remoteProxyOtherMachine`) :
- `Error: can't access property "invoke", window.__TAURI_INTERNALS__ is undefined` (x2)
- Aucun projet affiché.
## Cause racine (confirmée)
`crates/app-tauri/tauri.conf.json` utilise **un seul** `frontend/dist` pour deux besoins incompatibles :
- `build.frontendDist: "../../frontend/dist"` + `beforeBuildCommand: "npm --prefix ../frontend run build"``vite build` **sans** `VITE_TRANSPORT` → bundle **desktop** (transport `tauri`).
- `bundle.resources: { "../../frontend/dist": "web" }` → ce **même** bundle desktop est packagé en ressource `web/`.
Or `resolve_web_root()` (`crates/app-tauri/src/embedded_server.rs:498-517`) sert `resource_dir.join("web")`. Le serveur embarqué sert donc le bundle desktop au navigateur.
Le transport est figé **au build** par Vite : `resolveTransport()` (`frontend/src/app/di.tsx:39-46`) lit `import.meta.env.VITE_TRANSPORT`, constant-folded à la compilation.
**Preuve** dans `frontend/dist/assets/index-nl-2Eu6U.js` (build du 2026-07-17 08:00) :
```js
function Cb(){return"tauri"} // resolveTransport() figé sur tauri
```
Les deux jeux d'adapters sont bien présents dans le bundle, mais le sélecteur ne pointera jamais sur `createHttpWsGateways()`.
## Attendu
L'AppImage doit packager en ressource `web/` un bundle construit avec `VITE_TRANSPORT=http`, distinct du `frontendDist` desktop. Un seul `dist` ne peut pas servir les deux transports.
## Contexte
- `docs/server-client-mode-remote.md` documente déjà que le build web **doit** être produit en `VITE_TRANSPORT=http`, sinon exactement ce symptôme.
- Le carnet de #13 avait classé ce symptôme en « erreur de commande de test, pas un bug de code » — vrai à l'époque de `idea --serve --web-root`, mais le packaging AppImage de #66/#68 réintroduit le problème par défaut.
- #66 L4 anticipait le besoin (« produire les assets client Vite en `VITE_TRANSPORT=http` … packager dans `/usr/share/idea/web` ») ; la conf actuelle ne le fait pas.
- npm, jamais pnpm (mémoire `frontend-uses-npm-not-pnpm`).
## Workaround live
Build séparé + `IDEA_WEB_ROOT` (premier candidat de `resolve_web_root`, court-circuite la ressource packagée) :
```bash
cd frontend && VITE_TRANSPORT=http npx vite build --outDir dist-web --emptyOutDir
IDEA_WEB_ROOT=…/frontend/dist-web ./IdeA.AppImage
```
## DoD
- Build AppImage produit une ressource `web/` en transport http, `frontendDist` desktop inchangé.
- Vérif automatisable : le bundle servi ne doit pas résoudre le transport sur `tauri`.
- Validation live navigateur derrière reverse proxy : UI + projets listés, aucune erreur `__TAURI_INTERNALS__`.
- Rebuild AppImage livrable pour test utilisateur.

View File

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

View File

@ -0,0 +1,48 @@
---
id: "d54a1f20-03e6-4925-982b-923a37331749"
number: 75
title: "Écran d'appairage web : clavier numérique sur mobile alors que le code contient des lettres"
status: "qa"
priority: "medium"
sprint: null
links: [{"target":"#69","kind":"relatesTo"},{"target":"#74","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784277353320
updatedAt: 1784277530792
version: 2
---
## Symptôme (constaté live, téléphone)
Sur l'écran d'appairage web (`PairingScreen`), le clavier virtuel qui s'ouvre est un pavé **numérique uniquement**, alors que le code d'appairage à saisir contient des **lettres**. Impossible de saisir le code sans changer de clavier à la main.
## Cause racine (confirmée)
Le code généré côté serveur est **hexadécimal, 8 caractères, majuscules** (`0-9A-F`) :
```rust
// crates/web-server/src/lib.rs:2220
fn new_pairing_code() -> String {
Uuid::new_v4().simple().to_string().chars().take(8)
.collect::<String>().to_ascii_uppercase()
}
```
Or `frontend/src/features/web/PairingScreen.tsx:79` force `inputMode="numeric"` (introduit par #69 en supposant un code purement chiffré), et le placeholder `p. ex. 4821-93` (ligne 74) décrit un format qui n'existe pas.
## Attendu
- Le clavier mobile permet de saisir lettres **et** chiffres.
- Le placeholder reflète le format réel (hex 8 caractères majuscules).
- La saisie reste sans autocorrection ni capitalisation surprise ; le code étant en majuscules, la casse ne doit pas être un piège pour l'utilisateur.
## Périmètre
Frontend pur. Le format du code côté serveur n'est pas remis en cause par ce ticket.
## DoD
- Saisie possible au clavier mobile standard, lettres incluses.
- Placeholder cohérent avec `new_pairing_code()`.
- Test de non-régression sur le champ.

View File

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

View File

@ -0,0 +1,40 @@
---
id: "c91f547f-96f0-4c9c-9fde-dbe7d2772dbc"
number: 76
title: "Appairage : la comparaison du code est sensible à la casse côté serveur"
status: "open"
priority: "low"
sprint: null
links: [{"target":"#75","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784277540034
updatedAt: 1784277540034
version: 1
---
## Constat
`crates/web-server/src/lib.rs:1612` compare le code d'appairage reçu au code attendu par égalité stricte :
```rust
if request.code != state.pairing_code() { }
```
Or `new_pairing_code()` (ligne 2220) génère le code en majuscules. Un client qui envoie `3f7a9c21` au lieu de `3F7A9C21` est donc rejeté.
## Pourquoi c'est une dette et pas un bug bloquant
Le fix #75 rend l'écran web tolérant en normalisant la saisie en majuscules avant `POST /api/pair`. La tolérance vit donc **côté client** : tout autre client de l'API (curl, un futur client mobile natif, un test manuel) échouera encore sur une saisie minuscule, avec un message d'erreur qui n'explique pas pourquoi.
## Attendu
La comparaison serveur normalise la casse (et les espaces) avant égalité. La normalisation côté UI de #75 devient alors redondante mais inoffensive — pas besoin de la retirer.
## Vigilance
La comparaison doit rester en temps constant si elle l'est aujourd'hui : normaliser ne doit pas introduire de court-circuit exploitable en timing. À vérifier lors de l'implémentation.
## Périmètre
Backend pur, `crates/web-server`.

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": 68 "nextNumber": 80
} }

View File

@ -149,13 +149,13 @@
"issueRef": "#13", "issueRef": "#13",
"path": "13", "path": "13",
"title": "Server/client mode", "title": "Server/client mode",
"status": "inProgress", "status": "closed",
"priority": "low", "priority": "low",
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6", "sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
"assignedAgentIds": [ "assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641" "a6ced819-b893-4213-b003-9e9dc79b9641"
], ],
"updatedAt": 1784184002362 "updatedAt": 1784193634690
}, },
{ {
"issueRef": "#14", "issueRef": "#14",
@ -627,9 +627,9 @@
"title": "[Bug] Assistant IA d'édition de ticket : aucune réponse dans la conversation (stream sans Final non signalé)", "title": "[Bug] Assistant IA d'édition de ticket : aucune réponse dans la conversation (stream sans Final non signalé)",
"status": "inProgress", "status": "inProgress",
"priority": "high", "priority": "high",
"sprint": null, "sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
"assignedAgentIds": [], "assignedAgentIds": [],
"updatedAt": 1784095176619 "updatedAt": 1784194089605
}, },
{ {
"issueRef": "#61", "issueRef": "#61",
@ -669,19 +669,19 @@
"title": "Web : créer/ajouter un projet depuis le navigateur (folder browser serveur)", "title": "Web : créer/ajouter un projet depuis le navigateur (folder browser serveur)",
"status": "open", "status": "open",
"priority": "medium", "priority": "medium",
"sprint": null, "sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
"assignedAgentIds": [], "assignedAgentIds": [],
"updatedAt": 1784187610934 "updatedAt": 1784193766706
}, },
{ {
"issueRef": "#65", "issueRef": "#65",
"path": "65", "path": "65",
"title": "Serveur headless : extraire idea-serve du binaire Tauri (cœur partagé)", "title": "Serveur headless : extraire idea-serve du binaire Tauri (cœur partagé)",
"status": "open", "status": "closed",
"priority": "high", "priority": "high",
"sprint": null, "sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
"assignedAgentIds": [], "assignedAgentIds": [],
"updatedAt": 1784187583229 "updatedAt": 1784210420516
}, },
{ {
"issueRef": "#66", "issueRef": "#66",
@ -689,9 +689,9 @@
"title": "Image Docker serveur/client IdeA", "title": "Image Docker serveur/client IdeA",
"status": "open", "status": "open",
"priority": "medium", "priority": "medium",
"sprint": null, "sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
"assignedAgentIds": [], "assignedAgentIds": [],
"updatedAt": 1784187597004 "updatedAt": 1784193460805
}, },
{ {
"issueRef": "#67", "issueRef": "#67",
@ -699,9 +699,135 @@
"title": "Lock inter-process de l'app-data-dir (desktop ↔ idea --serve)", "title": "Lock inter-process de l'app-data-dir (desktop ↔ idea --serve)",
"status": "open", "status": "open",
"priority": "low", "priority": "low",
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
"assignedAgentIds": [],
"updatedAt": 1784193467784
},
{
"issueRef": "#68",
"path": "68",
"title": "Ajouter dans l'app desktop, la possibilité d'activer le serveur",
"status": "closed",
"priority": "medium",
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
"assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641"
],
"updatedAt": 1784277313706
},
{
"issueRef": "#69",
"path": "69",
"title": "Adaptibilité client téléphone",
"status": "qa",
"priority": "medium",
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
"assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641"
],
"updatedAt": 1784207736267
},
{
"issueRef": "#70",
"path": "70",
"title": "Pouvoir gérer les models AI locaux téléchargés via les serveur llamacpp",
"status": "open",
"priority": "low",
"sprint": null,
"assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641"
],
"updatedAt": 1784194049447
},
{
"issueRef": "#71",
"path": "71",
"title": "Diagnostic d'accessibilité du serveur : dire pourquoi ça ne marche pas, pas seulement que ça tourne",
"status": "open",
"priority": "medium",
"sprint": null, "sprint": null,
"assignedAgentIds": [], "assignedAgentIds": [],
"updatedAt": 1784187618710 "updatedAt": 1784210443885
},
{
"issueRef": "#72",
"path": "72",
"title": "Sécurité : donner un effet réel à la confiance reverse proxy (--trust-reverse-proxy est un drapeau creux)",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784236365445
},
{
"issueRef": "#73",
"path": "73",
"title": "TLS intégré à idea-serve : supprimer la cause racine de la cérémonie reverse proxy",
"status": "open",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784223044517
},
{
"issueRef": "#74",
"path": "74",
"title": "Le serveur embarqué sert le bundle desktop (Tauri) au navigateur — __TAURI_INTERNALS__ undefined",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784277308704
},
{
"issueRef": "#75",
"path": "75",
"title": "Écran d'appairage web : clavier numérique sur mobile alors que le code contient des lettres",
"status": "qa",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784277530792
},
{
"issueRef": "#76",
"path": "76",
"title": "Appairage : la comparaison du code est sensible à la casse côté serveur",
"status": "open",
"priority": "low",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784277540034
},
{
"issueRef": "#77",
"path": "77",
"title": "Appairage : appareils enregistrés persistants, révocables, et code éphémère à usage unique",
"status": "qa",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784287453378
},
{
"issueRef": "#78",
"path": "78",
"title": "Settings desktop : sections en langues mélangées (« Appareils » à côté de « AI Profiles », « Deployment »)",
"status": "open",
"priority": "low",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784284746767
},
{
"issueRef": "#79",
"path": "79",
"title": "Test flaky : PermissionsPanel « saves project defaults » échoue par intermittence",
"status": "open",
"priority": "low",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784287450082
} }
] ]
} }

View File

@ -30,14 +30,17 @@ pas d'outil de remise de résultat.
Ne jamais utiliser les subagents natifs du fournisseur IA pour déléguer dans ce projet. Ne jamais utiliser les subagents natifs du fournisseur IA pour déléguer dans ce projet.
**La liste des rôles ci-dessous fait foi, mais elle n'est pas la source de vérité des agents réellement déclarés.** Avant de conclure qu'un rôle n'existe pas, appeler `idea_list_agents` : le manifeste du projet fait autorité. Un rôle absent de ce document n'est pas un rôle absent du projet.
--- ---
## 3. Rôles ## 3. Rôles
- **Main** : chef d'orchestre. Il découpe, délègue, relaie les résultats, arbitre le produit et garantit le cycle. Il ne code pas les features. - **Main** : chef d'orchestre. Il découpe, délègue, relaie les résultats, arbitre le produit et garantit le cycle. Il ne code pas les features.
- **Architect** : propriétaire de l'architecture hexagonale, SOLID, ports/adapters, contrats, DTO, invariants et cartographie. - **Architect** : propriétaire de l'architecture hexagonale, SOLID, ports/adapters, contrats, DTO, invariants et cartographie.
- **UX** : propriétaire de la conception UI/UX — surfaces, navigation, libellés, hiérarchie de l'information, formulation des erreurs, parcours utilisateur. Décide de la forme ; Architect décide des frontières techniques.
- **DevBackend** : implémentation backend Rust selon les contrats validés par Architect. - **DevBackend** : implémentation backend Rust selon les contrats validés par Architect.
- **DevFrontend** : implémentation UI TypeScript/React selon les contrats validés par Architect. - **DevFrontend** : implémentation UI TypeScript/React selon les contrats validés par Architect et la conception validée par UX.
- **QA** : tests unitaires/intégration ciblés, exécution réelle, rapports d'échec, re-test jusqu'au vert. - **QA** : tests unitaires/intégration ciblés, exécution réelle, rapports d'échec, re-test jusqu'au vert.
- **Git** : propriétaire des branches, commits, merges/rebases locaux. Aucune action sortante sans validation explicite. - **Git** : propriétaire des branches, commits, merges/rebases locaux. Aucune action sortante sans validation explicite.
@ -48,16 +51,21 @@ Ne jamais utiliser les subagents natifs du fournisseur IA pour déléguer dans c
Pour toute feature ou correction applicative : Pour toute feature ou correction applicative :
```text ```text
1. Architect cadre ou valide l'architecture et les contrats. 1. UX conçoit la surface, dès qu'il y a de l'UI (voir ci-dessous).
2. Git décide de la branche locale. 2. Architect cadre ou valide l'architecture et les contrats.
3. DevBackend et/ou DevFrontend implémente. 3. Git décide de la branche locale.
4. QA écrit/exécute les tests. 4. DevBackend et/ou DevFrontend implémente.
5. Si KO : Main relaie le rapport réel au dev, puis retour QA. 5. QA écrit/exécute les tests.
6. Si OK : Git committe et décide du merge local éventuel. 6. Si KO : Main relaie le rapport réel au dev, puis retour QA.
7. Si OK : Git committe et décide du merge local éventuel.
``` ```
Une feature n'est terminée que lorsque les tests pertinents sont verts avec sortie réelle. Une feature n'est terminée que lorsque les tests pertinents sont verts avec sortie réelle.
**Quand solliciter UX — la règle.** Dès qu'une demande touche ce que l'utilisateur voit, lit ou manipule : nouvelle surface, nouvel écran, menu, libellé, message d'erreur, parcours, responsive. Ne pas dessiner l'UI à sa place pour « gagner du temps » ni la laisser tomber dans les mains d'un dev par défaut. UX passe **avant** Architect quand la forme conditionne les contrats (ce que l'UI doit exposer détermine les DTO), et **avec** lui quand une contrainte technique borne la conception.
UX ne bloque pas les tickets sans surface utilisateur (lots purement backend, seams, refactors internes).
--- ---
## 5. Produit : repères communs ## 5. Produit : repères communs
@ -74,7 +82,7 @@ Repères stables :
- Git, SSH et WSL intégrés. - Git, SSH et WSL intégrés.
- Stack validée : Tauri v2, Rust, TypeScript + React, xterm.js, portable-pty, git2/libgit2, russh/ssh2, `wsl.exe`. - Stack validée : Tauri v2, Rust, TypeScript + React, xterm.js, portable-pty, git2/libgit2, russh/ssh2, `wsl.exe`.
Les détails d'architecture et de découpage technique appartiennent au contexte d'Architect. Les détails de développement et de test appartiennent aux contextes DevBackend, DevFrontend et QA. Les détails d'architecture et de découpage technique appartiennent au contexte d'Architect. La conception des surfaces appartient au contexte d'UX. Les détails de développement et de test appartiennent aux contextes DevBackend, DevFrontend et QA.
--- ---
@ -93,4 +101,4 @@ Consulter la mémoire projet selon le besoin au lieu de recopier tous les détai
--- ---
*Dernière mise à jour : 2026-06-20* *Dernière mise à jour : 2026-07-16*

27
Cargo.lock generated
View File

@ -90,6 +90,7 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"uuid", "uuid",
"web-server",
] ]
[[package]] [[package]]
@ -100,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",
@ -904,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",
@ -5236,6 +5241,28 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "web-server"
version = "0.3.0"
dependencies = [
"application",
"backend",
"base64 0.22.1",
"bytes",
"cookie",
"domain",
"getrandom 0.3.4",
"hex",
"http",
"http-body-util",
"serde",
"serde_json",
"sha2",
"subtle",
"tokio",
"uuid",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.99" version = "0.3.99"

View File

@ -5,6 +5,7 @@ members = [
"crates/application", "crates/application",
"crates/infrastructure", "crates/infrastructure",
"crates/backend", "crates/backend",
"crates/web-server",
"crates/app-tauri", "crates/app-tauri",
] ]
@ -21,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.
@ -31,6 +36,7 @@ domain = { path = "crates/domain" }
application = { path = "crates/application" } application = { path = "crates/application" }
infrastructure = { path = "crates/infrastructure" } infrastructure = { path = "crates/infrastructure" }
backend = { path = "crates/backend" } backend = { path = "crates/backend" }
web-server = { path = "crates/web-server" }
# Tauri v2 # Tauri v2
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }

View File

@ -24,6 +24,7 @@ domain = { workspace = true }
application = { workspace = true } application = { workspace = true }
infrastructure = { workspace = true } infrastructure = { workspace = true }
backend = { workspace = true } backend = { workspace = true }
web-server = { workspace = true }
tauri = { workspace = true } tauri = { workspace = true }
tauri-plugin-dialog = { workspace = true } tauri-plugin-dialog = { workspace = true }
# `io-std` gives the headless `mcp-server` bridge access to # `io-std` gives the headless `mcp-server` bridge access to

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,
@ -61,9 +61,13 @@ use crate::dto::{
UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto,
UpdateTemplateRequestDto, WriteTerminalRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto,
}; };
use crate::embedded_server::{
EmbeddedServerStatusDto, ServerExposurePreviewDto, ServerExposureSettingsDto,
};
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).
@ -83,6 +87,220 @@ pub fn health(
.map_err(ErrorDto::from) .map_err(ErrorDto::from)
} }
/// `get_server_exposure_settings` — read persisted embedded-server exposure settings.
///
/// # Errors
/// Returns an [`ErrorDto`] when persisted settings are invalid.
#[tauri::command]
pub fn get_server_exposure_settings(
state: State<'_, AppState>,
) -> Result<ServerExposureSettingsDto, ErrorDto> {
state.embedded_server.get_settings()
}
/// `save_server_exposure_settings` — validate and persist embedded-server exposure settings.
///
/// # Errors
/// Returns an [`ErrorDto`] when settings are invalid or cannot be written.
#[tauri::command]
pub fn save_server_exposure_settings(
settings: ServerExposureSettingsDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
state.embedded_server.save_settings(settings)
}
/// `preview_server_exposure_settings` — derive LAN candidates and upstream URL.
///
/// # Errors
/// Returns an [`ErrorDto`] when settings are invalid.
#[tauri::command]
pub fn preview_server_exposure_settings(
settings: ServerExposureSettingsDto,
state: State<'_, AppState>,
) -> Result<ServerExposurePreviewDto, ErrorDto> {
state.embedded_server.preview(settings)
}
/// `embedded_server_status` — return the current embedded server status.
#[tauri::command]
pub fn embedded_server_status(state: State<'_, AppState>) -> EmbeddedServerStatusDto {
state.embedded_server.status()
}
/// `embedded_server_start` — start the embedded server with the shared backend core.
///
/// # Errors
/// Returns an [`ErrorDto`] when settings are invalid or the listener cannot start.
#[tauri::command]
pub async fn embedded_server_start(
state: State<'_, AppState>,
) -> Result<EmbeddedServerStatusDto, ErrorDto> {
state.embedded_server.start(state.core()).await
}
/// `embedded_server_stop` — stop the embedded server.
///
/// # Errors
/// Returns an [`ErrorDto`] when stopping the listener fails.
#[tauri::command]
pub async fn embedded_server_stop(
state: State<'_, AppState>,
) -> Result<EmbeddedServerStatusDto, ErrorDto> {
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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,819 @@
//! Desktop adapter for the embedded HTTP server lifecycle and exposure config.
//!
//! This module stays in the Tauri adapter layer: it owns persisted exposure
//! settings, starts/stops the embedded web server, and never crosses into the
//! domain/application layers.
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use backend::BackendCore;
use serde::{Deserialize, Serialize};
use web_server::{EmbeddedServerHandle, PairingCodeDto, ServerConfig, TrustedProxy};
use crate::dto::ErrorDto;
/// Remote exposure mode selected by the desktop settings UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ServerExposureMode {
/// Bind only loopback and serve local HTTP.
LocalOnly,
/// Bind loopback and expect a same-machine HTTPS reverse proxy.
RemoteProxyLocal,
/// Bind a LAN address and expect a trusted reverse proxy on another host.
RemoteProxyOtherMachine,
}
/// Persisted server exposure settings.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerExposureSettingsDto {
/// Exposure mode.
pub mode: ServerExposureMode,
/// TCP port to bind. `0` asks the OS for an ephemeral port.
pub port: u16,
/// Public HTTPS origin used by reverse-proxy modes.
pub public_origin: Option<String>,
/// Authorized proxy peers as IPs or CIDR ranges.
#[serde(default)]
pub trusted_proxies: Vec<String>,
/// LAN bind address for `remoteProxyOtherMachine`.
pub lan_bind_address: Option<String>,
}
/// Non-fatal diagnostic shown by the settings UI.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiagnosticWarningDto {
/// Stable warning code.
pub code: String,
/// Human-readable warning.
pub message: String,
}
/// Derived exposure preview for the desktop UI.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerExposurePreviewDto {
/// Candidate LAN addresses discovered by the backend.
pub candidate_lan_addresses: Vec<String>,
/// URL the reverse proxy should use as its upstream.
pub upstream_url: Option<String>,
/// Non-fatal diagnostics.
pub warnings: Vec<DiagnosticWarningDto>,
}
/// Current embedded server lifecycle state exposed to the desktop UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum EmbeddedServerStatusStateDto {
/// No embedded server is running.
Stopped,
/// Start is in progress.
Starting,
/// Server is running.
Running,
/// Stop is in progress.
Stopping,
/// Last start/stop failed.
Failed,
}
/// Embedded server status exposed to the desktop UI.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmbeddedServerStatusDto {
/// Lifecycle state.
pub state: EmbeddedServerStatusStateDto,
/// Local URL, when running.
pub local_url: Option<String>,
/// Public URL, when configured.
pub public_url: Option<String>,
/// Reverse-proxy upstream URL derived from settings.
pub upstream_url: Option<String>,
/// Last failure, when state is `failed`.
pub error: Option<ErrorDto>,
}
#[derive(Default)]
struct EmbeddedServerInner {
handle: Option<EmbeddedServerHandle>,
state: EmbeddedServerStatusStateDto,
upstream_url: Option<String>,
public_url: Option<String>,
error: Option<ErrorDto>,
}
impl Default for EmbeddedServerStatusStateDto {
fn default() -> Self {
Self::Stopped
}
}
/// Filesystem-backed exposure settings store.
#[derive(Debug, Clone)]
pub struct FsServerExposureSettingsStore {
path: PathBuf,
}
impl FsServerExposureSettingsStore {
/// Creates a store at `<app-data-dir>/deployment/server-exposure.json`.
#[must_use]
pub fn new(app_data_dir: PathBuf) -> Self {
Self {
path: app_data_dir.join("deployment").join("server-exposure.json"),
}
}
/// Reads persisted settings, or returns defaults when no file exists.
///
/// # Errors
/// Returns an [`ErrorDto`] when the file exists but is invalid.
pub fn read(&self) -> Result<ServerExposureSettingsDto, ErrorDto> {
if !self.path.exists() {
return Ok(default_settings());
}
let bytes = std::fs::read(&self.path).map_err(io_error)?;
let settings = serde_json::from_slice::<ServerExposureSettingsDto>(&bytes)
.map_err(|err| invalid_error(format!("invalid server exposure settings: {err}")))?;
validate_settings(&settings)?;
Ok(settings)
}
/// Validates and atomically writes settings with user-only permissions when
/// the platform supports them.
///
/// # Errors
/// Returns an [`ErrorDto`] when validation or persistence fails.
pub fn write(&self, settings: &ServerExposureSettingsDto) -> Result<(), ErrorDto> {
validate_settings(settings)?;
let bytes = serde_json::to_vec_pretty(settings).map_err(|err| ErrorDto {
code: "INTERNAL".to_owned(),
message: err.to_string(),
})?;
write_atomic_user_only(&self.path, &bytes).map_err(io_error)
}
}
/// Desktop-owned embedded server manager.
pub struct EmbeddedServerController {
app_data_dir: PathBuf,
resource_dir: Option<PathBuf>,
store: FsServerExposureSettingsStore,
inner: Mutex<EmbeddedServerInner>,
}
impl EmbeddedServerController {
/// Creates the controller.
#[must_use]
pub fn new(app_data_dir: PathBuf) -> Self {
Self::with_resource_dir(app_data_dir, None)
}
/// Creates the controller with the Tauri bundle resource directory.
#[must_use]
pub fn with_resource_dir(app_data_dir: PathBuf, resource_dir: Option<PathBuf>) -> Self {
Self {
store: FsServerExposureSettingsStore::new(app_data_dir.clone()),
app_data_dir,
resource_dir,
inner: Mutex::new(EmbeddedServerInner::default()),
}
}
/// Returns the current status.
#[must_use]
pub fn status(&self) -> EmbeddedServerStatusDto {
let inner = self.inner.lock().expect("embedded server mutex poisoned");
status_from_inner(&inner)
}
/// Reads persisted exposure settings.
///
/// # Errors
/// Returns an [`ErrorDto`] if persisted settings are invalid.
pub fn get_settings(&self) -> Result<ServerExposureSettingsDto, ErrorDto> {
self.store.read()
}
/// Persists exposure settings.
///
/// # Errors
/// Returns an [`ErrorDto`] if settings are invalid or cannot be written.
pub fn save_settings(&self, settings: ServerExposureSettingsDto) -> Result<(), ErrorDto> {
self.store.write(&settings)
}
/// Builds a preview for the provided settings without starting the server.
///
/// # Errors
/// Returns an [`ErrorDto`] if settings are invalid.
pub fn preview(
&self,
settings: ServerExposureSettingsDto,
) -> Result<ServerExposurePreviewDto, ErrorDto> {
validate_settings(&settings)?;
Ok(preview_settings(&settings))
}
/// Starts the embedded server. Idempotent when already running/starting.
///
/// # Errors
/// Returns an [`ErrorDto`] if settings are invalid or the listener fails.
pub async fn start(&self, core: Arc<BackendCore>) -> Result<EmbeddedServerStatusDto, ErrorDto> {
{
let mut inner = self.inner.lock().expect("embedded server mutex poisoned");
if inner.handle.is_some() || inner.state == EmbeddedServerStatusStateDto::Starting {
return Ok(status_from_inner(&inner));
}
inner.state = EmbeddedServerStatusStateDto::Starting;
inner.error = None;
}
let settings = match self.store.read() {
Ok(settings) => settings,
Err(err) => {
self.mark_failed(err.clone());
return Err(err);
}
};
let preview = preview_settings(&settings);
let config = match server_config_from_settings(
&settings,
self.app_data_dir.clone(),
resolve_web_root(self.resource_dir.as_deref())?,
) {
Ok(config) => config,
Err(err) => {
self.mark_failed(err.clone());
return Err(err);
}
};
match web_server::run_embedded_with_core(config, core).await {
Ok(handle) => {
let status = {
let mut inner = self.inner.lock().expect("embedded server mutex poisoned");
inner.public_url = settings.public_origin.clone();
inner.upstream_url = preview.upstream_url;
inner.state = EmbeddedServerStatusStateDto::Running;
inner.error = None;
inner.handle = Some(handle);
status_from_inner(&inner)
};
Ok(status)
}
Err(message) => {
let err = ErrorDto {
code: "PROCESS".to_owned(),
message,
};
self.mark_failed(err.clone());
Err(err)
}
}
}
/// 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.
///
/// # Errors
/// Returns an [`ErrorDto`] when stopping the listener task fails.
pub async fn stop(&self) -> Result<EmbeddedServerStatusDto, ErrorDto> {
let handle = {
let mut inner = self.inner.lock().expect("embedded server mutex poisoned");
let Some(handle) = inner.handle.take() else {
inner.state = EmbeddedServerStatusStateDto::Stopped;
inner.public_url = None;
inner.upstream_url = None;
return Ok(status_from_inner(&inner));
};
inner.state = EmbeddedServerStatusStateDto::Stopping;
handle
};
if let Err(message) = handle.stop().await {
let err = ErrorDto {
code: "PROCESS".to_owned(),
message,
};
self.mark_failed(err.clone());
return Err(err);
}
let mut inner = self.inner.lock().expect("embedded server mutex poisoned");
inner.state = EmbeddedServerStatusStateDto::Stopped;
inner.public_url = None;
inner.upstream_url = None;
inner.error = None;
Ok(status_from_inner(&inner))
}
fn mark_failed(&self, err: ErrorDto) {
let mut inner = self.inner.lock().expect("embedded server mutex poisoned");
inner.handle = None;
inner.state = EmbeddedServerStatusStateDto::Failed;
inner.public_url = None;
inner.upstream_url = None;
inner.error = Some(err);
}
}
fn status_from_inner(inner: &EmbeddedServerInner) -> EmbeddedServerStatusDto {
let error = (inner.state == EmbeddedServerStatusStateDto::Failed)
.then(|| inner.error.clone())
.flatten();
EmbeddedServerStatusDto {
state: inner.state,
local_url: inner.handle.as_ref().map(|handle| handle.url().to_owned()),
public_url: inner.public_url.clone(),
upstream_url: inner.upstream_url.clone(),
error,
}
}
fn default_settings() -> ServerExposureSettingsDto {
ServerExposureSettingsDto {
mode: ServerExposureMode::LocalOnly,
port: 17373,
public_origin: None,
trusted_proxies: Vec::new(),
lan_bind_address: None,
}
}
fn validate_settings(settings: &ServerExposureSettingsDto) -> Result<(), ErrorDto> {
match settings.mode {
ServerExposureMode::LocalOnly => {}
ServerExposureMode::RemoteProxyLocal => {
let origin = require_https_origin(settings)?;
validate_origin_shape(origin)?;
}
ServerExposureMode::RemoteProxyOtherMachine => {
let origin = require_https_origin(settings)?;
validate_origin_shape(origin)?;
let Some(addr) = &settings.lan_bind_address else {
return Err(invalid_error(
"remoteProxyOtherMachine requires lanBindAddress",
));
};
let ip = parse_ip(addr, "lanBindAddress")?;
if ip.is_loopback() || ip.is_unspecified() {
return Err(invalid_error(
"remoteProxyOtherMachine requires a concrete LAN bind address",
));
}
if settings.trusted_proxies.is_empty() {
return Err(invalid_error(
"remoteProxyOtherMachine requires at least one trustedProxies entry",
));
}
}
}
for proxy in &settings.trusted_proxies {
TrustedProxy::parse(proxy).map_err(invalid_error)?;
}
Ok(())
}
fn require_https_origin(settings: &ServerExposureSettingsDto) -> Result<&str, ErrorDto> {
let Some(origin) = settings.public_origin.as_deref() else {
return Err(invalid_error("remote exposure requires publicOrigin"));
};
if !origin.starts_with("https://") {
return Err(invalid_error(
"remote exposure requires publicOrigin to start with https://",
));
}
Ok(origin)
}
fn validate_origin_shape(origin: &str) -> Result<(), ErrorDto> {
if origin == "*" || origin.contains('*') {
return Err(invalid_error("publicOrigin must be exact, not a wildcard"));
}
if origin.ends_with('/') {
return Err(invalid_error("publicOrigin must not end with '/'"));
}
Ok(())
}
fn server_config_from_settings(
settings: &ServerExposureSettingsDto,
app_data_dir: PathBuf,
web_root: PathBuf,
) -> Result<ServerConfig, ErrorDto> {
validate_settings(settings)?;
let listen_ip = match settings.mode {
ServerExposureMode::LocalOnly | ServerExposureMode::RemoteProxyLocal => {
IpAddr::V4(Ipv4Addr::LOCALHOST)
}
ServerExposureMode::RemoteProxyOtherMachine => parse_ip(
settings.lan_bind_address.as_deref().unwrap_or_default(),
"lanBindAddress",
)?,
};
let allow_remote = settings.mode != ServerExposureMode::LocalOnly;
let trusted_proxies = settings
.trusted_proxies
.iter()
.map(|proxy| TrustedProxy::parse(proxy).map_err(invalid_error))
.collect::<Result<Vec<_>, _>>()?;
let config = ServerConfig {
listen: SocketAddr::new(listen_ip, settings.port),
public_origin: allow_remote
.then(|| settings.public_origin.clone())
.flatten(),
allow_remote,
trust_reverse_proxy: allow_remote,
trusted_proxies,
app_data_dir,
web_root,
new_code: false,
};
config.validate().map_err(invalid_error)?;
Ok(config)
}
fn preview_settings(settings: &ServerExposureSettingsDto) -> ServerExposurePreviewDto {
let candidate_lan_addresses = candidate_lan_addresses();
let bind_address = match settings.mode {
ServerExposureMode::LocalOnly | ServerExposureMode::RemoteProxyLocal => {
Some(Ipv4Addr::LOCALHOST.to_string())
}
ServerExposureMode::RemoteProxyOtherMachine => settings.lan_bind_address.clone(),
};
let upstream_url = bind_address.map(|addr| format!("http://{addr}:{}", settings.port));
let upstream_url = (settings.mode != ServerExposureMode::LocalOnly)
.then_some(upstream_url)
.flatten();
let mut warnings = Vec::new();
if settings.mode == ServerExposureMode::RemoteProxyOtherMachine
&& settings.trusted_proxies.is_empty()
{
warnings.push(DiagnosticWarningDto {
code: "missingTrustedProxy".to_owned(),
message:
"Add the IP address or CIDR of the reverse proxy that connects to this server."
.to_owned(),
});
}
ServerExposurePreviewDto {
candidate_lan_addresses,
upstream_url,
warnings,
}
}
fn candidate_lan_addresses() -> Vec<String> {
let mut addresses = Vec::new();
if let Ok(socket) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) {
if socket.connect((Ipv4Addr::new(192, 0, 2, 1), 80)).is_ok() {
if let Ok(addr) = socket.local_addr() {
if let IpAddr::V4(ip) = addr.ip() {
if !ip.is_loopback() && !ip.is_unspecified() {
addresses.push(ip.to_string());
}
}
}
}
}
addresses.sort();
addresses.dedup();
addresses
}
fn parse_ip(value: &str, field: &str) -> Result<IpAddr, ErrorDto> {
value
.parse::<IpAddr>()
.map_err(|_| invalid_error(format!("{field} must be an IP address")))
}
fn resolve_web_root(resource_dir: Option<&Path>) -> Result<PathBuf, ErrorDto> {
let explicit_web_root = std::env::var_os("IDEA_WEB_ROOT").map(PathBuf::from);
let candidates = web_root_candidates(explicit_web_root, resource_dir);
first_web_root(candidates).ok_or_else(|| {
invalid_error("web assets not found: build frontend/dist or set IDEA_WEB_ROOT")
})
}
fn web_root_candidates(
explicit_web_root: Option<PathBuf>,
resource_dir: Option<&Path>,
) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Some(path) = explicit_web_root {
candidates.push(path);
}
if let Some(resource_dir) = resource_dir {
candidates.push(resource_dir.join("web"));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push(dir.join("web"));
candidates.push(dir.join("frontend").join("dist"));
}
}
if let Ok(cwd) = std::env::current_dir() {
candidates.push(cwd.join("frontend").join("dist"));
}
candidates
}
fn first_web_root(candidates: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
candidates
.into_iter()
.find(|candidate| candidate.join("index.html").is_file())
}
fn write_atomic_user_only(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, bytes)?;
set_user_only_permissions(&tmp)?;
std::fs::rename(tmp, path)
}
#[cfg(unix)]
fn set_user_only_permissions(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
}
#[cfg(not(unix))]
fn set_user_only_permissions(_path: &Path) -> std::io::Result<()> {
Ok(())
}
fn invalid_error(message: impl Into<String>) -> ErrorDto {
ErrorDto {
code: "INVALID".to_owned(),
message: message.into(),
}
}
fn io_error(err: std::io::Error) -> ErrorDto {
ErrorDto {
code: "STORE".to_owned(),
message: err.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
struct EnvVarGuard {
key: &'static str,
previous: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &Path) -> Self {
let previous = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
if let Some(previous) = &self.previous {
std::env::set_var(self.key, previous);
} else {
std::env::remove_var(self.key);
}
}
}
fn tmp_app_data() -> PathBuf {
std::env::temp_dir().join(format!("idea-embedded-server-test-{}", Uuid::new_v4()))
}
fn tmp_web_root() -> PathBuf {
let web_root =
std::env::temp_dir().join(format!("idea-embedded-server-web-root-{}", Uuid::new_v4()));
std::fs::create_dir_all(&web_root).unwrap();
std::fs::write(
web_root.join("index.html"),
"<!doctype html><title>IdeA</title>",
)
.unwrap();
web_root
}
#[test]
fn web_root_candidates_keep_packaged_resource_before_exe_fallbacks() {
let explicit = tmp_app_data().join("explicit-web-root");
let resource_dir = tmp_app_data().join("resources");
let candidates = web_root_candidates(Some(explicit.clone()), Some(&resource_dir));
assert_eq!(candidates[0], explicit);
assert_eq!(candidates[1], resource_dir.join("web"));
}
#[test]
fn first_web_root_uses_first_candidate_with_index_html() {
let missing = tmp_app_data().join("missing-web-root");
let resource_dir = tmp_app_data().join("resources");
let resource_web = resource_dir.join("web");
std::fs::create_dir_all(&resource_web).unwrap();
std::fs::write(
resource_web.join("index.html"),
"<!doctype html><title>Packaged</title>",
)
.unwrap();
let later = tmp_web_root();
let resolved = first_web_root([missing, resource_web.clone(), later])
.expect("web root should resolve");
assert_eq!(resolved, resource_web);
}
#[test]
fn non_loopback_remote_requires_trusted_proxy() {
let settings = ServerExposureSettingsDto {
mode: ServerExposureMode::RemoteProxyOtherMachine,
port: 17373,
public_origin: Some("https://idea.example.com".to_owned()),
trusted_proxies: Vec::new(),
lan_bind_address: Some("192.0.2.75".to_owned()),
};
let err = validate_settings(&settings).unwrap_err();
assert_eq!(err.code, "INVALID");
assert!(err.message.contains("trustedProxies"));
}
#[test]
fn remote_requires_https_public_origin() {
let settings = ServerExposureSettingsDto {
mode: ServerExposureMode::RemoteProxyLocal,
port: 17373,
public_origin: Some("http://idea.example.com".to_owned()),
trusted_proxies: Vec::new(),
lan_bind_address: None,
};
let err = validate_settings(&settings).unwrap_err();
assert_eq!(err.code, "INVALID");
assert!(err.message.contains("https://"));
}
#[test]
fn local_only_derives_loopback_config() {
let settings = ServerExposureSettingsDto {
mode: ServerExposureMode::LocalOnly,
port: 0,
public_origin: Some("https://ignored.example".to_owned()),
trusted_proxies: Vec::new(),
lan_bind_address: None,
};
let config =
server_config_from_settings(&settings, tmp_app_data(), tmp_app_data()).unwrap();
assert_eq!(config.listen, "127.0.0.1:0".parse().unwrap());
assert!(!config.allow_remote);
assert_eq!(config.public_origin, None);
}
#[test]
fn remote_proxy_local_accepts_loopback_ephemeral_port() {
let settings = ServerExposureSettingsDto {
mode: ServerExposureMode::RemoteProxyLocal,
port: 0,
public_origin: Some("https://idea.example.com".to_owned()),
trusted_proxies: Vec::new(),
lan_bind_address: None,
};
let config =
server_config_from_settings(&settings, tmp_app_data(), tmp_app_data()).unwrap();
assert_eq!(config.listen, "127.0.0.1:0".parse().unwrap());
assert!(config.allow_remote);
assert!(config.trusted_proxies.is_empty());
}
#[test]
fn store_round_trips_without_pairing_code() {
let store = FsServerExposureSettingsStore::new(tmp_app_data());
let settings = ServerExposureSettingsDto {
mode: ServerExposureMode::RemoteProxyOtherMachine,
port: 17373,
public_origin: Some("https://idea.example.com".to_owned()),
trusted_proxies: vec!["192.0.2.22".to_owned()],
lan_bind_address: Some("192.0.2.75".to_owned()),
};
store.write(&settings).unwrap();
let read = store.read().unwrap();
assert_eq!(read, settings);
}
#[tokio::test]
async fn stop_is_idempotent_when_already_stopped() {
let controller = EmbeddedServerController::new(tmp_app_data());
let first = controller.stop().await.unwrap();
let second = controller.stop().await.unwrap();
assert!(matches!(first.state, EmbeddedServerStatusStateDto::Stopped));
assert!(matches!(
second.state,
EmbeddedServerStatusStateDto::Stopped
));
}
#[tokio::test]
async fn start_is_idempotent_and_stop_stops_running_server() {
let app_data = tmp_app_data();
let web_root = tmp_web_root();
let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root);
let controller = EmbeddedServerController::new(app_data.clone());
controller
.save_settings(ServerExposureSettingsDto {
mode: ServerExposureMode::LocalOnly,
port: 0,
public_origin: None,
trusted_proxies: Vec::new(),
lan_bind_address: None,
})
.unwrap();
let core = Arc::new(BackendCore::build(app_data));
let first = controller.start(Arc::clone(&core)).await.unwrap();
let second = controller.start(core).await.unwrap();
assert!(matches!(first.state, EmbeddedServerStatusStateDto::Running));
assert!(matches!(
second.state,
EmbeddedServerStatusStateDto::Running
));
assert_eq!(first.local_url, second.local_url);
assert!(first
.local_url
.as_deref()
.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"));
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();
assert!(matches!(
stopped.state,
EmbeddedServerStatusStateDto::Stopped
));
assert!(stopped.local_url.is_none());
}
#[tokio::test]
async fn start_with_invalid_persisted_settings_fails_closed() {
let app_data = tmp_app_data();
let controller = EmbeddedServerController::new(app_data.clone());
let bad = ServerExposureSettingsDto {
mode: ServerExposureMode::RemoteProxyLocal,
port: 17373,
public_origin: Some("http://idea.example.com".to_owned()),
trusted_proxies: Vec::new(),
lan_bind_address: None,
};
let path = app_data.join("deployment").join("server-exposure.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, serde_json::to_vec(&bad).unwrap()).unwrap();
let core = Arc::new(BackendCore::build(app_data));
let err = controller.start(core).await.unwrap_err();
let status = controller.status();
assert_eq!(err.code, "INVALID");
assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@
pub mod chat; pub mod chat;
pub mod commands; pub mod commands;
pub mod dto; pub mod dto;
pub mod embedded_server;
pub mod events; pub mod events;
pub mod mcp_bridge; pub mod mcp_bridge;
pub mod mcp_endpoint; pub mod mcp_endpoint;
@ -95,13 +96,14 @@ pub fn run() {
.path() .path()
.app_data_dir() .app_data_dir()
.expect("failed to resolve the app data directory"); .expect("failed to resolve the app data directory");
let resource_dir = app.path().resource_dir().ok();
// Point the orchestrator's best-effort diagnostics at a persistent file // Point the orchestrator's best-effort diagnostics at a persistent file
// (`<app-data>/logs/idea.log`) so inter-agent rendezvous beacons survive a // (`<app-data>/logs/idea.log`) so inter-agent rendezvous beacons survive a
// click-launched AppImage (whose stderr is otherwise discarded). Best-effort: // click-launched AppImage (whose stderr is otherwise discarded). Best-effort:
// if the file can't be opened the beacons simply stay on stderr. // if the file can't be opened the beacons simply stay on stderr.
application::diag::set_log_path(app_data_dir.join("logs").join("idea.log")); application::diag::set_log_path(app_data_dir.join("logs").join("idea.log"));
application::diag!("[startup] IdeA launched; diagnostics log armed"); application::diag!("[startup] IdeA launched; diagnostics log armed");
let app_state = AppState::build(app_data_dir); let app_state = AppState::build_with_resource_dir(app_data_dir, resource_dir);
// Wire the domain event bus → Tauri events relay. // Wire the domain event bus → Tauri events relay.
events::spawn_relay(app.handle().clone(), &app_state.event_bus); events::spawn_relay(app.handle().clone(), &app_state.event_bus);
@ -129,6 +131,7 @@ pub fn run() {
let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents); let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents);
let model_servers = let model_servers =
std::sync::Arc::clone(&state.ensure_local_model_server); std::sync::Arc::clone(&state.ensure_local_model_server);
let embedded_server = std::sync::Arc::clone(&state.embedded_server);
let open_projects = state.open_project_ids(); let open_projects = state.open_project_ids();
let handles = state.terminal_sessions.handles(); let handles = state.terminal_sessions.handles();
tauri::async_runtime::block_on(async move { tauri::async_runtime::block_on(async move {
@ -148,6 +151,7 @@ pub fn run() {
let _ = pty.kill(&h).await; let _ = pty.kill(&h).await;
} }
let _ = model_servers.stop_on_app_exit().await; let _ = model_servers.stop_on_app_exit().await;
let _ = embedded_server.stop().await;
}); });
} }
@ -282,6 +286,18 @@ pub fn run() {
commands::cancel_background_task, commands::cancel_background_task,
commands::retry_background_task, commands::retry_background_task,
commands::list_background_tasks, commands::list_background_tasks,
commands::get_server_exposure_settings,
commands::save_server_exposure_settings,
commands::preview_server_exposure_settings,
commands::embedded_server_status,
commands::embedded_server_start,
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");

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@ use infrastructure::TicketToolProvider;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::chat::ChatBridge; use crate::chat::ChatBridge;
use crate::embedded_server::EmbeddedServerController;
use crate::pty::PtyBridge; use crate::pty::PtyBridge;
use crate::tickets::AppTicketToolProvider; use crate::tickets::AppTicketToolProvider;
@ -38,6 +39,8 @@ pub struct AppState {
pub pty_bridge: Arc<PtyBridge>, pub pty_bridge: Arc<PtyBridge>,
/// Structured reply to Tauri Channel bridge registry. /// Structured reply to Tauri Channel bridge registry.
pub chat_bridge: Arc<ChatBridge>, pub chat_bridge: Arc<ChatBridge>,
/// Desktop-owned embedded HTTP server lifecycle manager.
pub embedded_server: Arc<EmbeddedServerController>,
/// Project currently focused by the main window; panel-only windows follow it. /// Project currently focused by the main window; panel-only windows follow it.
focused_project: Mutex<Option<FocusedProjectDto>>, focused_project: Mutex<Option<FocusedProjectDto>>,
} }
@ -48,7 +51,14 @@ impl AppState {
/// into the backend core. /// into the backend core.
#[must_use] #[must_use]
pub fn build(app_data_dir: PathBuf) -> Self { pub fn build(app_data_dir: PathBuf) -> Self {
let core = Arc::new(BackendCore::build(app_data_dir)); Self::build_with_resource_dir(app_data_dir, None)
}
/// Builds the shared backend core with the Tauri bundle resource directory
/// available to desktop-only adapters.
#[must_use]
pub fn build_with_resource_dir(app_data_dir: PathBuf, resource_dir: Option<PathBuf>) -> Self {
let core = Arc::new(BackendCore::build(app_data_dir.clone()));
core.ticket_tool_binder core.ticket_tool_binder
.bind(Arc::new(AppTicketToolProvider { .bind(Arc::new(AppTicketToolProvider {
create: Arc::clone(&core.create_issue), create: Arc::clone(&core.create_issue),
@ -66,10 +76,21 @@ impl AppState {
core, core,
pty_bridge: Arc::new(PtyBridge::new()), pty_bridge: Arc::new(PtyBridge::new()),
chat_bridge: Arc::new(ChatBridge::new()), chat_bridge: Arc::new(ChatBridge::new()),
embedded_server: Arc::new(EmbeddedServerController::with_resource_dir(
app_data_dir,
resource_dir,
)),
focused_project: Mutex::new(None), focused_project: Mutex::new(None),
} }
} }
/// Clones the shared backend core for adapter components that must share
/// the desktop composition root.
#[must_use]
pub fn core(&self) -> Arc<BackendCore> {
Arc::clone(&self.core)
}
/// Updates the focused project state. `None` means no project is focused/open. /// Updates the focused project state. `None` means no project is focused/open.
pub fn set_focused_project(&self, project: Option<FocusedProjectDto>) { pub fn set_focused_project(&self, project: Option<FocusedProjectDto>) {
*self *self

View File

@ -1743,15 +1743,6 @@ fn required_str<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, TicketTo
.ok_or_else(|| TicketToolError::new("invalid", format!("missing {key}"))) .ok_or_else(|| TicketToolError::new("invalid", format!("missing {key}")))
} }
impl ErrorDto {
fn invalid(message: impl Into<String>) -> Self {
Self {
code: "INVALID".to_owned(),
message: message.into(),
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -6,7 +6,8 @@
"build": { "build": {
"frontendDist": "../../frontend/dist", "frontendDist": "../../frontend/dist",
"devUrl": "http://localhost:5173", "devUrl": "http://localhost:5173",
"beforeDevCommand": "npm --prefix ../frontend run dev" "beforeDevCommand": "npm --prefix ../frontend run dev",
"beforeBuildCommand": "npm --prefix ../frontend run build:bundle"
}, },
"app": { "app": {
"windows": [ "windows": [
@ -24,6 +25,9 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["appimage", "nsis"], "targets": ["appimage", "nsis"],
"icon": ["icons/icon.png"] "icon": ["icons/icon.png"],
"resources": {
"../../frontend/dist-web": "web"
}
} }
} }

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,

3495
crates/backend/src/dto.rs Normal file

File diff suppressed because it is too large Load Diff

1326
crates/backend/src/events.rs Normal file

File diff suppressed because it is too large Load Diff

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,20 +72,23 @@ 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,
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
}; };
pub mod dto;
pub mod events;
pub mod mcp_endpoint; pub mod mcp_endpoint;
pub mod openai_tools; pub mod openai_tools;
pub mod stream; pub mod stream;
@ -796,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).
@ -1132,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>) ---
@ -1145,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),
@ -2314,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

@ -159,7 +159,7 @@ pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint {
/// `/tmp/.mount_*` qui disparaît au redémarrage ⇒ `ENOENT` au respawn du pont MCP. /// `/tmp/.mount_*` qui disparaît au redémarrage ⇒ `ENOENT` au respawn du pont MCP.
/// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si /// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si
/// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale. /// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale.
pub(crate) fn idea_exe_path() -> Option<String> { pub fn idea_exe_path() -> Option<String> {
std::env::var("APPIMAGE").ok().or_else(|| { std::env::var("APPIMAGE").ok().or_else(|| {
std::env::current_exe() std::env::current_exe()
.ok() .ok()

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

@ -0,0 +1,37 @@
[package]
name = "web-server"
version = "0.3.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "IdeA headless HTTP/WebSocket driving adapter, reusable by desktop and server binaries."
[lib]
name = "web_server"
path = "src/lib.rs"
[[bin]]
name = "idea-serve"
path = "src/bin/idea-serve.rs"
[dependencies]
backend = { workspace = true }
application = { workspace = true }
domain = { workspace = true }
tokio = { workspace = true, features = ["io-std", "rt", "net"] }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
base64 = "0.22"
bytes = "1.11"
cookie = "0.18"
getrandom = { workspace = true }
hex = { workspace = true }
http = "1.4"
http-body-util = "0.1"
sha2 = { workspace = true }
subtle = { workspace = true }
[features]
vector-http = ["backend/vector-http"]
vector-onnx = ["backend/vector-onnx"]

View File

@ -0,0 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
web_server::run_from_args(std::env::args().skip(1).collect())
}

5966
crates/web-server/src/lib.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -59,8 +59,42 @@ idea --serve \
--trust-reverse-proxy --trust-reverse-proxy
``` ```
The proxy must forward the same origin for the SPA, `/api/*`, and `/api/ws`. This example is for a reverse proxy running on the same machine as the server.
Do not expose the backend on a public non-loopback address without TLS proxying. If the proxy runs on another machine, bind an address reachable by that proxy,
for example `--listen 192.0.2.75:17373`, and point the proxy upstream to that
address and port. Also authorize only that proxy peer:
```bash
idea --serve \
--listen 192.0.2.75:17373 \
--allow-remote \
--public-origin https://idea.example.com \
--trust-reverse-proxy \
--trusted-proxy 192.0.2.22
```
The link from proxy to server is still plain HTTP on the LAN; only the public
access is HTTPS.
For any non-loopback bind, keep the full remote HTTPS configuration:
`--allow-remote`, `--public-origin https://...`, `--trust-reverse-proxy`, and at
least one `--trusted-proxy IP_OR_CIDR`. `ServerConfig::validate()` rejects a
non-loopback bind without it.
That check covers the configuration, not reality: it cannot verify that a proxy
is actually terminating HTTPS in front of the server. Do not expose the backend
on a public non-loopback address without TLS proxying.
The proxy must forward the same origin for the SPA, `/api/*`, and `/api/ws`, and
must send `X-Forwarded-Proto: https`. Forwarding the public host is recommended
for diagnostics, but the strict access check remains the `Origin` match against
`--public-origin`.
`--public-origin` is matched by strict equality against the request `Origin`:
open the UI through the configured domain, not through the server IP, otherwise
API calls are rejected with 403.
With a LAN bind, also check the host firewall. If the server is reachable from
the local machine but times out from every other host, open the port for the LAN
subnet or, preferably, only for the proxy IP. Avoid opening it broadly: the
server does not provide its own TLS.
Secrets must never be placed in URLs; pairing uses `POST /api/pair` and then an Secrets must never be placed in URLs; pairing uses `POST /api/pair` and then an
`HttpOnly`, `Secure`, `SameSite=Strict` session cookie. `HttpOnly`, `Secure`, `SameSite=Strict` session cookie.

7
frontend/.env.web Normal file
View File

@ -0,0 +1,7 @@
# Vite mode `web` (`vite build --mode web`, ticket #74 lot F1): the bundle the
# embedded server serves to a browser talks HTTP+WS instead of Tauri IPC.
#
# This lives in a mode env file rather than an inline `VITE_TRANSPORT=http vite
# build` prefix because the npm scripts must also run on Windows (NSIS bundle),
# where that shell syntax does not exist.
VITE_TRANSPORT=http

View File

@ -2,7 +2,14 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- `viewport-fit=cover` (#69) lets the web client paint under a notch and
exposes the `env(safe-area-inset-*)` values the shell pads with. Zoom is
deliberately left unrestricted (no `maximum-scale`/`user-scalable=no`):
pinch-zoom is an accessibility affordance. -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>IdeA</title> <title>IdeA</title>
</head> </head>
<body> <body>

View File

@ -6,6 +6,9 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"build:web": "tsc --noEmit && vite build --mode web --outDir dist-web --emptyOutDir",
"build:bundle": "npm run typecheck && vite build --outDir dist --emptyOutDir && vite build --mode web --outDir dist-web --emptyOutDir",
"test:bundle-transport": "node scripts/assert-bundle-transport.mjs",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",

View File

@ -0,0 +1,78 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
const EXPECTED_TRANSPORT_BY_DIR = new Map([
["dist", "tauri"],
["dist-web", "http"],
]);
async function readJavaScriptAssets(buildDir) {
const assetsDir = join(process.cwd(), buildDir, "assets");
let entries;
try {
entries = await readdir(assetsDir, { withFileTypes: true });
} catch (error) {
throw new Error(
`Cannot read ${assetsDir}. Run npm run build:bundle before this check. ${error.message}`,
);
}
const files = entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".js"))
.map((entry) => join(assetsDir, entry.name));
if (files.length === 0) {
throw new Error(`No JavaScript assets found in ${assetsDir}.`);
}
return Promise.all(
files.map(async (file) => ({
file,
source: await readFile(file, "utf8"),
})),
);
}
function transportMarkers(source) {
return [...source.matchAll(/__IDEA_TRANSPORT__="([a-z]+)"/g)].map(
(match) => match[1],
);
}
async function assertBundleTransport(buildDir, expectedTransport) {
const assets = await readJavaScriptAssets(buildDir);
const markers = assets.flatMap(({ file, source }) =>
transportMarkers(source).map((transport) => ({ file, transport })),
);
if (markers.length === 0) {
throw new Error(
`${buildDir} does not publish a __IDEA_TRANSPORT__ marker in its built JavaScript assets.`,
);
}
const unexpected = markers.filter(
({ transport }) => transport !== expectedTransport,
);
if (unexpected.length > 0) {
const details = unexpected
.map(({ file, transport }) => `${file}: ${transport}`)
.join("\n");
throw new Error(
`${buildDir} resolves the wrong transport; expected ${expectedTransport}.\n${details}`,
);
}
console.log(
`${buildDir}: __IDEA_TRANSPORT__="${expectedTransport}" (${markers.length} marker${markers.length === 1 ? "" : "s"})`,
);
}
try {
for (const [buildDir, expectedTransport] of EXPECTED_TRANSPORT_BY_DIR) {
await assertBundleTransport(buildDir, expectedTransport);
}
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}

View File

@ -0,0 +1,82 @@
/**
* Tauri adapter for {@link DesktopServerGateway} (ticket #68). One of the only
* places that calls `invoke()`; features reach it exclusively through the port.
*
* Commands and payload keys are camelCase, matching the backend DTO convention.
* `save_server_exposure_settings` / `preview_server_exposure_settings` take the
* settings directly under a `settings` key (no `request` wrapper).
*
* **Polling note.** The backend exposes no status *event* — `embedded_server.rs`
* emits nothing — so `onStatusChanged` is implemented here by polling
* `embedded_server_status`. That keeps the port shape push-like (and swappable
* for a real event later) while the polling schedule stays an adapter detail.
*/
import { invoke } from "@tauri-apps/api/core";
import type {
EmbeddedServerStatus,
ServerExposurePreview,
ServerExposureSettings,
Unsubscribe,
} from "@/domain";
import type { DesktopServerGateway } from "@/ports";
/** Status poll interval (ms). Cheap, in-process command; transitions are coarse. */
const STATUS_POLL_MS = 2000;
export class TauriDesktopServerGateway implements DesktopServerGateway {
constructor(private readonly pollMs: number = STATUS_POLL_MS) {}
getExposureSettings(): Promise<ServerExposureSettings> {
return invoke<ServerExposureSettings>("get_server_exposure_settings");
}
async saveExposureSettings(settings: ServerExposureSettings): Promise<void> {
await invoke("save_server_exposure_settings", { settings });
}
previewExposure(
settings: ServerExposureSettings,
): Promise<ServerExposurePreview> {
return invoke<ServerExposurePreview>("preview_server_exposure_settings", {
settings,
});
}
status(): Promise<EmbeddedServerStatus> {
return invoke<EmbeddedServerStatus>("embedded_server_status");
}
start(): Promise<EmbeddedServerStatus> {
return invoke<EmbeddedServerStatus>("embedded_server_start");
}
stop(): Promise<EmbeddedServerStatus> {
return invoke<EmbeddedServerStatus>("embedded_server_stop");
}
async onStatusChanged(
handler: (status: EmbeddedServerStatus) => void,
): Promise<Unsubscribe> {
let stopped = false;
const timer = setInterval(() => {
void this.status().then(
(status) => {
// The unsubscribe may land while a poll is in flight; drop late results
// so a torn-down consumer never gets called.
if (!stopped) handler(status);
},
() => {
// A failed poll is not a status: swallow it and let the next tick try
// again, rather than tearing the subscription down.
},
);
}, this.pollMs);
return () => {
stopped = true;
clearInterval(timer);
};
}
}

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,
@ -42,6 +43,7 @@ import {
HttpTicketGateway, HttpTicketGateway,
} from "./streamGateways"; } from "./streamGateways";
import { import {
WebDesktopServerGateway,
WebFocusedProjectGateway, WebFocusedProjectGateway,
WebRemoteGateway, WebRemoteGateway,
WebWindowGateway, WebWindowGateway,
@ -121,10 +123,14 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
remote: new WebRemoteGateway(), remote: new WebRemoteGateway(),
profile: new HttpProfileGateway(http), profile: new HttpProfileGateway(http),
modelServer: new HttpModelServerGateway(http), modelServer: new HttpModelServerGateway(http),
// Desktop-only (#68): the web client is served *by* the embedded server and
// must not reconfigure it.
desktopServer: new WebDesktopServerGateway(),
template: new HttpTemplateGateway(http), template: new HttpTemplateGateway(http),
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

@ -9,8 +9,15 @@
* browser to replace `pickFolder`), it gets its own lot. * browser to replace `pickFolder`), it gets its own lot.
*/ */
import type { GatewayError, Unsubscribe } from "@/domain";
import type { import type {
EmbeddedServerStatus,
GatewayError,
ServerExposurePreview,
ServerExposureSettings,
Unsubscribe,
} from "@/domain";
import type {
DesktopServerGateway,
FocusedProject, FocusedProject,
FocusedProjectGateway, FocusedProjectGateway,
RemoteGateway, RemoteGateway,
@ -75,3 +82,40 @@ export class WebRemoteGateway implements RemoteGateway {
return unsupportedOnWeb("Remote (SSH/WSL) connection"); return unsupportedOnWeb("Remote (SSH/WSL) connection");
} }
} }
/**
* Web stub: the embedded server is desktop-only (ticket #68). A web client is
* *served by* this very server, so letting it reconfigure or stop the server
* would let a remote device saw off the branch it sits on. The desktop app owns
* this surface; every call fails explicitly rather than reaching for Tauri.
*/
export class WebDesktopServerGateway implements DesktopServerGateway {
// `async` on purpose: these must *reject* rather than throw synchronously, so
// callers using `.then(ok, err)` (not just `await`) still see the failure.
async getExposureSettings(): Promise<ServerExposureSettings> {
return unsupportedOnWeb("Embedded server settings");
}
async saveExposureSettings(_settings: ServerExposureSettings): Promise<void> {
return unsupportedOnWeb("Embedded server settings");
}
async previewExposure(
_settings: ServerExposureSettings,
): Promise<ServerExposurePreview> {
return unsupportedOnWeb("Embedded server settings");
}
async status(): Promise<EmbeddedServerStatus> {
return unsupportedOnWeb("Embedded server status");
}
async start(): Promise<EmbeddedServerStatus> {
return unsupportedOnWeb("Starting the embedded server");
}
async stop(): Promise<EmbeddedServerStatus> {
return unsupportedOnWeb("Stopping the embedded server");
}
onStatusChanged(
_handler: (status: EmbeddedServerStatus) => void,
): Promise<Unsubscribe> {
// Never fires on web; a no-op unsubscribe keeps callers' teardown honest.
return Promise.resolve(() => {});
}
}

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

@ -20,10 +20,12 @@ import { TauriTerminalGateway } from "./terminal";
import { TauriLayoutGateway } from "./layout"; import { TauriLayoutGateway } from "./layout";
import { TauriProfileGateway } from "./profile"; import { TauriProfileGateway } from "./profile";
import { TauriModelServerGateway } from "./modelServer"; import { TauriModelServerGateway } from "./modelServer";
import { TauriDesktopServerGateway } from "./desktopServer";
import { TauriTemplateGateway } from "./template"; 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";
@ -60,10 +62,12 @@ export function createTauriGateways(): Gateways {
remote: new TauriRemoteGateway(), remote: new TauriRemoteGateway(),
profile: new TauriProfileGateway(), profile: new TauriProfileGateway(),
modelServer: new TauriModelServerGateway(), modelServer: new TauriModelServerGateway(),
desktopServer: new TauriDesktopServerGateway(),
template: new TauriTemplateGateway(), template: new TauriTemplateGateway(),
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(),
@ -83,10 +87,12 @@ export {
TauriLayoutGateway, TauriLayoutGateway,
TauriProfileGateway, TauriProfileGateway,
TauriModelServerGateway, TauriModelServerGateway,
TauriDesktopServerGateway,
TauriTemplateGateway, TauriTemplateGateway,
TauriSkillGateway, TauriSkillGateway,
TauriMemoryGateway, TauriMemoryGateway,
TauriEmbedderGateway, TauriEmbedderGateway,
TauriDeviceGateway,
TauriGitGateway, TauriGitGateway,
TauriPermissionGateway, TauriPermissionGateway,
TauriWorkStateGateway, TauriWorkStateGateway,

View File

@ -8,9 +8,11 @@ import type {
Agent, Agent,
AgentDrift, AgentDrift,
AgentProfile, AgentProfile,
DiagnosticWarning,
DomainEvent, DomainEvent,
EmbedderEngines, EmbedderEngines,
EmbedderProfile, EmbedderProfile,
EmbeddedServerStatus,
FirstRunState, FirstRunState,
GatewayError, GatewayError,
GitBranches, GitBranches,
@ -30,12 +32,16 @@ import type {
MemoryLink, MemoryLink,
MemoryType, MemoryType,
EffectivePermissions, EffectivePermissions,
PairedDevice,
PairingCode,
PermissionSet, PermissionSet,
Project, Project,
ProjectPermissions, ProjectPermissions,
ProjectWorkState, ProjectWorkState,
ProfileAvailability, ProfileAvailability,
ResumableAgent, ResumableAgent,
ServerExposurePreview,
ServerExposureSettings,
Skill, Skill,
ReplyChunk, ReplyChunk,
SkillScope, SkillScope,
@ -63,6 +69,8 @@ import type {
CreateAgentInput, CreateAgentInput,
CreateMemoryInput, CreateMemoryInput,
CreateSkillInput, CreateSkillInput,
DesktopServerGateway,
DeviceGateway,
EmbedderGateway, EmbedderGateway,
CreateTemplateInput, CreateTemplateInput,
Gateways, Gateways,
@ -1373,6 +1381,146 @@ export class MockModelServerGateway implements ModelServerGateway {
} }
} }
/** Mirror of the backend `default_settings()` (ticket #68). */
const DEFAULT_EXPOSURE_SETTINGS: ServerExposureSettings = {
mode: "localOnly",
port: 17373,
trustedProxies: [],
};
/** Fixed LAN candidates so the offline UI has something to pick from. */
const MOCK_LAN_ADDRESSES = ["192.168.1.42", "10.0.0.17"];
function invalid(message: string): never {
const err: GatewayError = { code: "INVALID", message };
throw err;
}
/**
* In-memory embedded-server gateway (ticket #68).
*
* Mirrors the backend `validate_settings` / `preview_settings` closely enough
* that the Deployment UI — including its rejection paths — is testable offline.
* It is never the source of truth: production runs the real Tauri commands.
* `candidateLanAddresses` is fixed data here precisely because the UI must take
* addresses from the gateway rather than deriving them.
*/
export class MockDesktopServerGateway implements DesktopServerGateway {
private settings: ServerExposureSettings = structuredClone(
DEFAULT_EXPOSURE_SETTINGS,
);
private current: EmbeddedServerStatus = { state: "stopped" };
private readonly handlers = new Set<(s: EmbeddedServerStatus) => void>();
async getExposureSettings(): Promise<ServerExposureSettings> {
return structuredClone(this.settings);
}
async saveExposureSettings(settings: ServerExposureSettings): Promise<void> {
this.validate(settings);
this.settings = structuredClone(settings);
}
async previewExposure(
settings: ServerExposureSettings,
): Promise<ServerExposurePreview> {
// The real `preview_server_exposure_settings` validates *before* previewing,
// so an incomplete draft is rejected rather than previewed. Mirrored here —
// the UI relies on it as its validation authority.
this.validate(settings);
const warnings: DiagnosticWarning[] = [];
if (
settings.mode === "remoteProxyOtherMachine" &&
settings.trustedProxies.length === 0
) {
// Mirrors the backend's `missingTrustedProxy` warning — which the backend
// itself cannot currently reach, since `validate_settings` rejects an
// empty `trustedProxies` for this mode first. Kept aligned on purpose: if
// the backend reorders, the mock already matches.
warnings.push({
code: "missingTrustedProxy",
message:
"Add the IP address or CIDR of the reverse proxy that connects to this server.",
});
}
const bindAddress =
settings.mode === "remoteProxyOtherMachine"
? settings.lanBindAddress
: "127.0.0.1";
const upstreamUrl =
settings.mode !== "localOnly" && bindAddress
? `http://${bindAddress}:${settings.port}`
: undefined;
return {
candidateLanAddresses: [...MOCK_LAN_ADDRESSES],
upstreamUrl,
warnings,
};
}
async status(): Promise<EmbeddedServerStatus> {
return structuredClone(this.current);
}
async start(): Promise<EmbeddedServerStatus> {
// The backend re-validates on start; a bad config fails there, not silently.
this.validate(this.settings);
const preview = await this.previewExposure(this.settings);
this.emit({
state: "running",
localUrl: `http://127.0.0.1:${this.settings.port}`,
publicUrl:
this.settings.mode === "localOnly"
? undefined
: this.settings.publicOrigin,
upstreamUrl: preview.upstreamUrl,
});
return structuredClone(this.current);
}
async stop(): Promise<EmbeddedServerStatus> {
this.emit({ state: "stopped" });
return structuredClone(this.current);
}
async onStatusChanged(
handler: (status: EmbeddedServerStatus) => void,
): Promise<Unsubscribe> {
this.handlers.add(handler);
return () => this.handlers.delete(handler);
}
/** Test hook: force a status (e.g. a `failed` state) and notify subscribers. */
setStatus(status: EmbeddedServerStatus): void {
this.emit(status);
}
private emit(status: EmbeddedServerStatus): void {
this.current = status;
for (const handler of this.handlers) handler(structuredClone(status));
}
private validate(settings: ServerExposureSettings): void {
if (settings.mode !== "localOnly") {
const origin = settings.publicOrigin;
if (!origin) invalid("remote exposure requires publicOrigin");
if (!origin.startsWith("https://")) {
invalid("remote exposure requires publicOrigin to start with https://");
}
if (origin.includes("*")) invalid("publicOrigin must be exact, not a wildcard");
if (origin.endsWith("/")) invalid("publicOrigin must not end with '/'");
}
if (settings.mode === "remoteProxyOtherMachine") {
if (!settings.lanBindAddress) {
invalid("remoteProxyOtherMachine requires lanBindAddress");
}
if (settings.trustedProxies.length === 0) {
invalid("remoteProxyOtherMachine requires at least one trustedProxies entry");
}
}
}
}
/** /**
* Stateful in-memory template gateway. * Stateful in-memory template gateway.
* *
@ -1893,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>();
@ -2630,10 +2838,12 @@ export function createMockGateways(): Gateways {
remote: new MockRemoteGateway(), remote: new MockRemoteGateway(),
profile: new MockProfileGateway(), profile: new MockProfileGateway(),
modelServer: new MockModelServerGateway(), modelServer: new MockModelServerGateway(),
desktopServer: new MockDesktopServerGateway(),
template: new MockTemplateGateway(agentGateway), template: new MockTemplateGateway(agentGateway),
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

@ -16,6 +16,8 @@ describe("createMockGateways", () => {
expect(Object.keys(gateways).sort()).toEqual([ expect(Object.keys(gateways).sort()).toEqual([
"agent", "agent",
"conversation", "conversation",
"desktopServer",
"device",
"embedder", "embedder",
"focusedProject", "focusedProject",
"git", "git",

View File

@ -16,6 +16,7 @@ import {
shouldUseMock, shouldUseMock,
shouldUseHttp, shouldUseHttp,
} from "./di"; } from "./di";
import { transportFromEnv } from "./transport";
afterEach(() => { afterEach(() => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
@ -66,6 +67,28 @@ describe("resolveTransport / web (HTTP+WS) selection (ticket #13, F1)", () => {
}); });
}); });
describe("transportFromEnv (ticket #74, F1)", () => {
// The build config derives `__IDEA_TRANSPORT__` from this same predicate, so
// pinning it here pins both the runtime transport and the build constant.
it("selects http only for the exact opt-in value", () => {
expect(transportFromEnv("http")).toBe("http");
});
it.each([undefined, "", "tauri", "HTTP", "http ", "web"])(
"falls back to tauri for %o",
(value) => {
expect(transportFromEnv(value)).toBe("tauri");
},
);
it("agrees with shouldUseHttp for the value the build reads", () => {
vi.stubEnv("VITE_TRANSPORT", "http");
expect(shouldUseHttp()).toBe(transportFromEnv("http") === "http");
vi.stubEnv("VITE_TRANSPORT", "");
expect(shouldUseHttp()).toBe(transportFromEnv("") === "http");
});
});
describe("DIProvider / useGateways", () => { describe("DIProvider / useGateways", () => {
it("provides explicit gateways to consumers", () => { it("provides explicit gateways to consumers", () => {
const gateways = resolveGatewaysMock(); const gateways = resolveGatewaysMock();

View File

@ -18,11 +18,11 @@ import type { Gateways } from "@/ports";
import { createTauriGateways } from "@/adapters"; import { createTauriGateways } from "@/adapters";
import { createMockGateways } from "@/adapters/mock"; import { createMockGateways } from "@/adapters/mock";
import { createHttpWsGateways } from "@/adapters/http"; import { createHttpWsGateways } from "@/adapters/http";
import { transportFromEnv, type Transport } from "./transport";
const GatewaysContext = createContext<Gateways | null>(null); const GatewaysContext = createContext<Gateways | null>(null);
/** The selected transport backing the gateways. */ export type { Transport };
export type Transport = "mock" | "http" | "tauri";
/** Whether the mock adapters should be used (env-driven, overridable in tests). */ /** Whether the mock adapters should be used (env-driven, overridable in tests). */
export function shouldUseMock(): boolean { export function shouldUseMock(): boolean {
@ -36,14 +36,13 @@ export function shouldUseMock(): boolean {
* and is never affected (ticket #13, lot F1). * and is never affected (ticket #13, lot F1).
*/ */
export function shouldUseHttp(): boolean { export function shouldUseHttp(): boolean {
return import.meta.env.VITE_TRANSPORT === "http"; return transportFromEnv(import.meta.env.VITE_TRANSPORT) === "http";
} }
/** Resolves which transport to use. Mock wins, then explicit web, else Tauri. */ /** Resolves which transport to use. Mock wins, then explicit web, else Tauri. */
export function resolveTransport(): Transport { export function resolveTransport(): Transport {
if (shouldUseMock()) return "mock"; if (shouldUseMock()) return "mock";
if (shouldUseHttp()) return "http"; return transportFromEnv(import.meta.env.VITE_TRANSPORT);
return "tauri";
} }
/** Resolves the gateway set for the current environment. */ /** Resolves the gateway set for the current environment. */

View File

@ -19,6 +19,14 @@ if (!root) {
// follows the main window's focused project; otherwise the full app. // follows the main window's focused project; otherwise the full app.
const viewParams = parseViewWindowParams(window.location.search); const viewParams = parseViewWindowParams(window.location.search);
// Ticket #74, F1: publish the transport this bundle was built for. `define`
// inlines it as a literal, and assigning it is a side effect, so it survives
// minification and tree-shaking — a built bundle can be identified (by QA, or
// from devtools) without grepping for a minified symbol. Both adapter sets ship
// in either bundle, so their mere presence proves nothing.
(window as unknown as { __IDEA_TRANSPORT__?: string }).__IDEA_TRANSPORT__ =
__IDEA_TRANSPORT__;
// Ticket #13, F2: in web (HTTP) transport mode the browser client renders the // Ticket #13, F2: in web (HTTP) transport mode the browser client renders the
// pairing-gated, read-only `WebApp`. Desktop (Tauri) is unchanged — it renders // pairing-gated, read-only `WebApp`. Desktop (Tauri) is unchanged — it renders
// the full `App` (or a detached view window). Detached windows are desktop-only. // the full `App` (or a detached view window). Detached windows are desktop-only.

View File

@ -0,0 +1,19 @@
/**
* The transport signal, shared by the app and the Vite build config.
*
* `vite.config.ts` imports {@link transportFromEnv} to compute the
* `__IDEA_TRANSPORT__` build constant from the very same `VITE_TRANSPORT`
* variable that {@link shouldUseHttp} reads (ticket #74, F1). One predicate,
* one variable: the greppable constant and the resolved transport cannot drift.
*/
/** The selected transport backing the gateways. */
export type Transport = "mock" | "http" | "tauri";
/**
* The transport a raw `VITE_TRANSPORT` value selects, mock aside. Web is opt-in
* (`"http"`) so the desktop path stays the default and is never affected.
*/
export function transportFromEnv(value: string | undefined): "http" | "tauri" {
return value === "http" ? "http" : "tauri";
}

View File

@ -117,6 +117,97 @@ export interface ModelServerCommandPreview {
display: string; display: string;
} }
/**
* How the embedded web server is exposed (ticket #68, mirror of the backend
* `ServerExposureMode`).
*
* - `localOnly` — binds loopback, no remote access.
* - `remoteProxyLocal` — binds loopback; an HTTPS reverse proxy runs on *this*
* machine and reaches IdeA over loopback.
* - `remoteProxyOtherMachine` — binds a concrete LAN address so a proxy on
* *another* host can reach it; that proxy must be declared in
* `trustedProxies`.
*/
export type ServerExposureMode =
| "localOnly"
| "remoteProxyLocal"
| "remoteProxyOtherMachine";
/**
* Persisted embedded-server exposure settings (mirror of the backend
* `ServerExposureSettingsDto`).
*
* The backend validates every field on save/start; the UI never derives network
* facts (LAN addresses, upstream URL) from these — it asks `previewExposure`.
*/
export interface ServerExposureSettings {
mode: ServerExposureMode;
/** TCP port to bind. `0` asks the OS for an ephemeral port. */
port: number;
/** Public HTTPS origin, required by both remote modes. */
publicOrigin?: string;
/**
* Peers allowed to contact IdeA, as IPs or CIDRs. **Not** a listen address.
* Required (non-empty) by `remoteProxyOtherMachine`.
*/
trustedProxies: string[];
/**
* Concrete LAN address to bind, required by `remoteProxyOtherMachine`. Must
* come from {@link ServerExposurePreview.candidateLanAddresses} — the UI is
* never allowed to invent one.
*/
lanBindAddress?: string;
}
/** A non-fatal diagnostic from the exposure preview (never blocks a start). */
export interface DiagnosticWarning {
/** Stable warning code (e.g. `missingTrustedProxy`). */
code: string;
/** Human-readable, actionable message. */
message: string;
}
/**
* Backend-derived preview of a draft exposure config (mirror of
* `ServerExposurePreviewDto`). The backend is the sole authority on LAN
* addresses and the proxy upstream URL.
*/
export interface ServerExposurePreview {
/** LAN addresses the backend discovered on this host. */
candidateLanAddresses: string[];
/** URL the reverse proxy should target; absent in `localOnly`. */
upstreamUrl?: string;
/** Non-fatal diagnostics to surface alongside the form. */
warnings: DiagnosticWarning[];
}
/** Embedded-server lifecycle state (mirror of `EmbeddedServerStatusStateDto`). */
export type EmbeddedServerState =
| "stopped"
| "starting"
| "running"
| "stopping"
| "failed";
/**
* Embedded-server status (mirror of `EmbeddedServerStatusDto`).
*
* 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 {
state: EmbeddedServerState;
/** Local URL, when running. */
localUrl?: string;
/** Public URL, when a remote mode is configured. */
publicUrl?: string;
/** Upstream URL to hand to the reverse proxy. */
upstreamUrl?: string;
/** Last failure, when `state` is `failed`. */
error?: GatewayError;
}
/** A domain event relayed from the backend (tagged union on `type`). */ /** A domain event relayed from the backend (tagged union on `type`). */
export type DomainEvent = export type DomainEvent =
| { type: "projectCreated"; projectId: string } | { type: "projectCreated"; projectId: string }
@ -1230,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>
);
}

Some files were not shown because too many files have changed in this diff Show More