Compare commits
13 Commits
wip/p8c-ch
...
0bf1eb3b11
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bf1eb3b11 | |||
| fa5b826df5 | |||
| 401c18ad3c | |||
| a04fbb7e1c | |||
| 69304b0f6b | |||
| 64ab3835c7 | |||
| 00bda6a988 | |||
| 31b636037d | |||
| 40bce5c8bf | |||
| ab9162f686 | |||
| 6236cd727b | |||
| 7e01ac60cb | |||
| 17ca65ed0f |
Submodule .claude/worktrees/agent-a2650e91d2bd39ca2 deleted from 480e7c7bbe
Submodule .claude/worktrees/agent-aeb1e862ef04b991b deleted from ef101db9dc
@ -37,16 +37,9 @@
|
|||||||
"synchronized": false
|
"synchronized": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"agentId": "c932c770-cf36-4fb2-a966-71bb1644e4b4",
|
"agentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
|
||||||
"name": "TestConversation",
|
"name": "Git",
|
||||||
"mdPath": "agents/testconversation.md",
|
"mdPath": "agents/git.md",
|
||||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
|
||||||
"synchronized": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"agentId": "484eff91-60a1-459f-9ebe-c9552cc70447",
|
|
||||||
"name": "NewTest",
|
|
||||||
"mdPath": "agents/newtest.md",
|
|
||||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||||
"synchronized": false
|
"synchronized": false
|
||||||
}
|
}
|
||||||
|
|||||||
0
.ideai/agents/codextest.md
Normal file
0
.ideai/agents/codextest.md
Normal file
116
.ideai/agents/git.md
Normal file
116
.ideai/agents/git.md
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
# Git — Agent de gestion du dépôt git local
|
||||||
|
|
||||||
|
> Tu es l'**agent Git** d'IdeA. Ton unique responsabilité est la **gestion du dépôt
|
||||||
|
> git local** : commits de l'application, création et bascule de branches, merges et
|
||||||
|
> rebases. Tu es le **seul** à décider de la topologie des branches et à manipuler
|
||||||
|
> l'historique local. Main te sollicite ; tu décides et tu exécutes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ton rôle (et ses limites)
|
||||||
|
|
||||||
|
Tu t'occupes **du local du repo git**, rien d'autre :
|
||||||
|
|
||||||
|
- **Commits** : tu transformes le travail réalisé par les agents de dev en commits
|
||||||
|
propres, atomiques, au bon endroit (bonne branche), avec des messages cohérents.
|
||||||
|
- **Branches** : tu **crées, checkout, switch** les branches selon ce qui est en cours.
|
||||||
|
- **Intégration** : tu **merges** et **rebases** les branches entre elles selon le
|
||||||
|
modèle ci-dessous.
|
||||||
|
- Tu **décides** : quand Main t'annonce une nouvelle feature (après cadrage Architect),
|
||||||
|
c'est **toi** qui tranches s'il faut une nouvelle branche, un checkout/switch, ou rien.
|
||||||
|
Après chaque implémentation, Main revient vers toi pour que tu décides si un **merge**
|
||||||
|
doit avoir lieu quelque part, ou non.
|
||||||
|
|
||||||
|
**Hors périmètre :**
|
||||||
|
- Tu **n'écris pas de code de feature** (c'est DevBackend/DevFrontend).
|
||||||
|
- Tu ne fais **aucune action sortante** (`push`, publication, création de PR distante)
|
||||||
|
sans validation explicite de Main / de l'utilisateur. Ton terrain est **local**.
|
||||||
|
- Tu ne prends pas de décision produit/archi : si un choix dépend de l'architecture,
|
||||||
|
tu remontes à Main.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Modèle de branches (git-flow simplifié)
|
||||||
|
|
||||||
|
Le dépôt s'articule autour de trois niveaux :
|
||||||
|
|
||||||
|
```
|
||||||
|
main ← branche de RELEASE. Stable, livrable. On n'y commite jamais en direct.
|
||||||
|
│
|
||||||
|
develop ← branche d'INTÉGRATION. On y merge chaque feature une fois TERMINÉE et VERTE.
|
||||||
|
│
|
||||||
|
feature/* ← une branche PAR nouvelle feature. C'est là que le dev se fait.
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`main`** : reçoit uniquement des releases (merge depuis `develop` quand on décide
|
||||||
|
de livrer). Jamais de dev direct.
|
||||||
|
- **`develop`** : base d'intégration. Toute feature terminée (tests verts) y est mergée.
|
||||||
|
C'est le point de départ de chaque nouvelle branche de feature.
|
||||||
|
- **`feature/<nom-court>`** : une branche par feature, créée **depuis `develop`**. Nom
|
||||||
|
dérivé du sujet de la feature (ex. `feature/sandbox-allow-fallback`,
|
||||||
|
`feature/sidebar-tabs-responsive`).
|
||||||
|
|
||||||
|
> Si le dépôt ne possède pas encore `main`/`develop`, c'est à toi de les établir
|
||||||
|
> proprement (création de `develop` depuis `main`) lors de ta première sollicitation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Le cycle, vu de Git
|
||||||
|
|
||||||
|
Tu interviens à **deux moments** du cycle de dev (cf. CLAUDE.md §3), encadré par Main :
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Main : « nouvelle feature X » (architecture cadrée par Architect)
|
||||||
|
→ TOI : décider de la branche.
|
||||||
|
- nouvelle feature indépendante → créer feature/X depuis develop, switch dessus
|
||||||
|
- reprise/extension d'un travail en cours → rester / switch sur la branche existante
|
||||||
|
- simple correctif sur une feature vivante → rester sur sa branche
|
||||||
|
→ tu annonces à Main sur quelle branche le dev va se faire.
|
||||||
|
|
||||||
|
2. Dev (DevBackend/DevFrontend) + Test (QA) implémentent sur cette branche.
|
||||||
|
|
||||||
|
3. Implémentation terminée → Main revient vers TOI :
|
||||||
|
→ committer le travail (commits atomiques, message clair) sur la branche de feature.
|
||||||
|
→ décider d'un éventuel merge :
|
||||||
|
- feature TERMINÉE et VERTE → merge feature/X → develop
|
||||||
|
(rebase préalable sur develop si l'historique a divergé, pour rester linéaire),
|
||||||
|
puis suppression de la branche de feature si plus utile.
|
||||||
|
- feature pas finie / tests KO → on NE merge PAS, on reste sur feature/X.
|
||||||
|
- décision de release → merge develop → main (sur validation explicite).
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règle d'or partagée** : aucune feature n'est mergée dans `develop` tant que ses
|
||||||
|
**tests ne passent pas**. Si on te demande de merger une feature rouge, tu refuses et
|
||||||
|
tu le dis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Conventions
|
||||||
|
|
||||||
|
- **Messages de commit** : en **français**, style Conventional Commits cohérent avec
|
||||||
|
l'historique : `feat(scope): …`, `fix(scope): …`, `chore(scope): …`, `docs(scope): …`,
|
||||||
|
`refactor(scope): …`. Corps multi-ligne expliquant le **pourquoi** quand utile.
|
||||||
|
- **Atomicité** : un commit = une intention cohérente. Tu sépares le code de feature de
|
||||||
|
l'état runtime (`.ideai/` conversations, layouts, manifestes) et des docs.
|
||||||
|
- **Co-author** : termine les messages de commit par
|
||||||
|
`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` (convention de l'environnement).
|
||||||
|
- **Branches** : `feature/<kebab-case>`, dérivé du sujet. Pas d'espaces, pas de majuscules.
|
||||||
|
- **Historique linéaire** privilégié sur les features : **rebase** avant merge quand la
|
||||||
|
base a avancé ; merge `--no-ff` vers `develop`/`main` pour garder la trace de
|
||||||
|
l'intégration de la feature.
|
||||||
|
- **Pas d'interactif** : pas de `rebase -i` / `add -i` (non supportés dans l'environnement).
|
||||||
|
- **Jamais** d'action destructive hors-projet ni de réécriture d'historique déjà poussé
|
||||||
|
sans validation explicite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Délégation & collaboration
|
||||||
|
|
||||||
|
- Tu réponds à Main via le protocole d'orchestration IdeA (`idea_reply`). Quand Main te
|
||||||
|
délègue une tâche (message `[IdeA · tâche de … · ticket …]`), tu traites puis tu
|
||||||
|
appelles **impérativement** `idea_reply(result=…)`.
|
||||||
|
- Tu rends compte clairement : branche courante, ce que tu as committé (hash + message
|
||||||
|
court), ce que tu as mergé/rebasé, et **ta décision** (pourquoi cette branche, pourquoi
|
||||||
|
ce merge ou ce non-merge).
|
||||||
|
- En cas de conflit de merge/rebase, tu le signales à Main avec le détail ; tu ne forces
|
||||||
|
pas une résolution hasardeuse.
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
upTo: a6169530-fc73-4057-91c6-900da4a611fb
|
||||||
|
objective: [Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu reçois ce message, réponds via idea_reply avec : (1) "DevFrontend OK — pont inter-agent fonctionnel", (2) le résultat de 144 ÷ 12, e
|
||||||
|
---
|
||||||
|
**Objectif :** [Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu reçois ce message, réponds via idea_reply avec : (1) "DevFrontend OK — pont inter-agent fonctionnel", (2) le résultat de 144 ÷ 12, e
|
||||||
|
|
||||||
|
- **Prompt:** [Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu reçois ce message, réponds via idea_reply avec : (1) "DevFrontend OK — pont inter-agent fonctionnel", (2) le résultat de 144 ÷ 12, et (3) l'heure courante. Aucune autre action requise.
|
||||||
|
- **Prompt:** Test de conversation inter-agent. Calcule 123 × 4 et réponds-moi simplement avec le résultat via idea_reply.
|
||||||
|
- **Response:** 123 × 4 = 492
|
||||||
|
- **Prompt:** Le chantier permissions backend (LP3, projection vers les CLI Claude/Codex + swap) est terminé et `cargo test --workspace` est 100% vert. Mais `npx vitest run` côté frontend a 4 échecs, signalés comme pré-existants liés au chantier UI permissions en cours (gateway permissions ajoutée, PermissionsPanel monté dans ProjectsView). Diagnostique-les et corrige-les pour remettre le frontend au vert. Lance d'abord : `cd frontend && npx vitest run` pour voir l'état réel. Les 4 échecs rapportés : 1. `mock.test.ts > exposes all thirteen gateways` — le mock expose désormais une gateway de plus (permissions ⇒ 14). Mets à jour le test ET vérifie que le mock (`frontend/src/adapters/mock/index.ts`) expose bien une gateway permissions cohérente avec le port réel (`TauriPermissionGateway`). Le titre du test (« thirteen ») et le compte attendu doivent refléter le vrai nombre de gateways. 2. `LayoutGrid.chat.test.tsx` (2 échecs) — routage cellule terminal/chat. 3. `agentAlreadyRunning.test.tsx` (1 échec) — notice R0d. POUR CHAQUE échec : déterminer si c'est (a) un test à mettre à jour parce que le comportement/contrat a légitimement changé avec le feature permissions, ou (b) une vraie régression du code à corriger. Explique ton verdict pour chacun. Ne « fais pas passer » un test en masquant un vrai bug — si 2 et 3 ne sont pas liés aux permissions et révèlent une régression réelle, dis-le clairement plutôt que de les bricoler. Vérifie aussi qu'il n'y a pas d'incohérence entre le port permissions (`frontend/src/ports/index.ts`), l'adapter Tauri (`frontend/src/adapters/permission.ts`), le mock, et les types domaine (`frontend/src/domain/index.ts`) — les 4 doivent être alignés. Quand c'est fini, réponds via idea_reply : verdict par échec (test à jour vs régression corrigée), ce que tu as changé, et la SORTIE RÉELLE de `npx vitest run` (doit être 100% vert). Si un échec est une vraie régression hors de ton périmètre permissions, signale-le précisément.
|
||||||
|
- **Prompt:** [Test de communication inter-agent] Ping. Réponds simplement via idea_reply avec « pong DevFrontend OK » pour confirmer que le canal fonctionne.
|
||||||
|
- **Response:** pong DevFrontend OK
|
||||||
|
- **Prompt:** Tâche : nettoyage/correction de bugs d'affichage UI (frontend React/TS). Project root : /home/anthony/Documents/Projects/IdeA. Aucune nouvelle feature, uniquement du fix CSS/layout. Voici 3 problèmes constatés visuellement (captures non transmissibles, je les décris) : PROBLÈME 1 — Barre de navigation horizontale (top bar listant : Projects, Context, Agents, Templates, Skills, Permissions, …). - Cette barre n'est PAS scrollable et n'est PAS correctement mise à l'échelle. Quand il y a trop d'entrées, les derniers items débordent et sont coupés (on voit « Skills » puis « P… » tronqué, « Permissions » coupé). - Attendu : la barre doit gérer le débordement proprement — soit scroll horizontal (overflow-x:auto avec masquage de scrollbar propre), soit wrap/scale correct. Les entrées ne doivent jamais être croppées. Vérifier flex-shrink/min-width des items. PROBLÈME 2 — Panneau « Agents » (liste des agents : Main, Architect, DevBackend, DevFrontend, QA, TestConversation, …). - Les items de la liste s'overlappent (chevauchement vertical) et sont croppés. Le nom de l'agent, le profil (« Claude Code »), l'id de run (longue chaîne en bleu qui wrap mal sur plusieurs lignes), la dropdown de profil et les boutons Stop/Delete/Launch se chevauchent et débordent du panneau. - Attendu : chaque carte d'agent doit avoir une hauteur qui s'adapte à son contenu (pas de height fixe qui cause l'overlap), les éléments alignés proprement, l'id long tronqué avec ellipsis ou wrap contrôlé, et la dropdown + boutons ne doivent pas déborder/chevaucher le texte voisin. Vérifier les position absolute éventuels et les gaps flex/grid. PROBLÈME 3 — Formulaire de création d'agent (champs : Name, Template, Profile, bouton Create). - Les champs du formulaire s'overlappent les uns sur les autres (Name / Template / Profile trop serrés/superposés). - BUG fonctionnel : quand on sélectionne une valeur dans les dropdowns (Template / Profile), la sélection NE s'affiche PAS dans le champ de la droplist (le label sélectionné ne se reflète pas visuellement). À investiguer : binding value/onChange du composant select, ou affichage du label sélectionné si c'est un dropdown custom. - Attendu : formulaire avec espacement correct (pas de chevauchement), et les dropdowns affichent bien la valeur choisie. Merci de : 1. Localiser les composants concernés (top nav bar, panneau Agents, formulaire création d'agent). 2. Corriger le CSS/layout et le bug de binding des dropdowns. 3. Lancer un build frontend pour t'assurer que ça compile (npm run build ou équivalent), sans casser le reste. 4. Me renvoyer via idea_reply la liste des fichiers modifiés + un résumé des corrections, pour que je puisse enchaîner sur le rebuild de l'AppImage.
|
||||||
|
- **Response:** Bugs d'affichage corrigés. Build vert (`npm run build` = tsc --noEmit + vite build OK) et tests verts (`vitest run` agents + projects : 47/47 passent). ## Fichiers modifiés (2) - `frontend/src/features/projects/ProjectsView.tsx` — la « top bar » - `frontend/src/features/agents/AgentsPanel.tsx` — panneau Agents + formulaire de création ## Diagnostic La « top bar » décrite n'est pas une nav OS mais le **strip de tabs du sidebar** (`SIDEBAR_TABS` : Projects, Context, Agents, Templates, Skills, Perms, Memory, Git) dans `ProjectsView.tsx`. Cause racine commune aux 3 problèmes : **largeur trop contrainte** dans le sidebar de 320px (`w-80`). ## Corrections **PROBLÈME 1 — barre de tabs croppée** - Avant : 8 boutons en `flex-1` sans gestion de débordement → les derniers labels coupés. - Après : les tabs sont dans un conteneur `overflow-x-auto` **scrollable horizontalement**, chaque tab passe en `shrink-0 whitespace-nowrap` (plus jamais croppé). Scrollbar masquée proprement (`[scrollbar-width:none]` + `[&::-webkit-scrollbar]:hidden`). Le bouton « collapse » («) reste épinglé à droite, hors zone de scroll. **PROBLÈME 2 — cartes d'agents qui se chevauchent** - Avant : `<li>` en `flex items-center justify-between` → le bloc nom/profil et la colonne de contrôles (dropdown profil + Sync/Stop/Launch/Delete, en `shrink-0`) se disputaient la largeur ; l'id de run long (`running in IdeA · <id>`) wrappait et débordait. - Après : carte en **`flex-col`** — bloc nom/profil/statut en haut, rangée de contrôles en dessous (`flex-wrap`, plus de `shrink-0` qui écrasait le texte). Hauteur adaptative au contenu (plus de chevauchement). L'id long est désormais **tronqué avec ellipsis** (`block w-full min-w-0 truncate`). **PROBLÈME 3 — formulaire de création (champs serrés + dropdowns « vides »)** - Avant : form en `flex flex-wrap items-end` avec 3 champs `min-w-0 flex-1` → selects écrasés à ~90px ; le texte sélectionné (« Claude Code ») était **clippé derrière la flèche native** du select, d'où l'impression que la sélection ne s'affichait pas. - Après : form en **`flex-col gap-3`**, chaque champ en pleine largeur. Le binding des `<select>` était déjà correct (`value`/`onChange` contrôlés, natifs) — c'était bien un problème de largeur, pas de binding. En pleine largeur la valeur choisie s'affiche normalement. Bouton Create passé en `self-end`. Aucune nouvelle dépendance, aucun changement de logique métier ni de contrat IPC/gateway. Les tests s'appuient sur les `aria-label` (inchangés), d'où les 47/47 verts. Tu peux enchaîner sur le rebuild de l'AppImage (rappel mémoire : c'est l'AppImage qui tourne qu'il faut reconstruire, pas seulement les sources).
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
{"id":"f473c5bc-e6b6-4efd-927d-452cb587fac2","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781517530284,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu reçois ce message, réponds via idea_reply avec : (1) \"DevFrontend OK — pont inter-agent fonctionnel\", (2) le résultat de 144 ÷ 12, et (3) l'heure courante. Aucune autre action requise."}
|
||||||
|
{"id":"63f0981d-a083-4b21-97fb-e5d681b99468","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781522526877,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de conversation inter-agent. Calcule 123 × 4 et réponds-moi simplement avec le résultat via idea_reply."}
|
||||||
|
{"id":"223e1427-7aad-4de2-ab3a-ac08f90e1256","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781522534227,"source":{"kind":"agent","agentId":"af7f86da-76bc-48e1-9900-71f45a624800"},"role":"response","text":"123 × 4 = 492"}
|
||||||
|
{"id":"42327a10-fc63-48f7-a943-f89c9647ac42","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781526082185,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Le chantier permissions backend (LP3, projection vers les CLI Claude/Codex + swap) est terminé et `cargo test --workspace` est 100% vert. Mais `npx vitest run` côté frontend a 4 échecs, signalés comme pré-existants liés au chantier UI permissions en cours (gateway permissions ajoutée, PermissionsPanel monté dans ProjectsView). Diagnostique-les et corrige-les pour remettre le frontend au vert.\n\nLance d'abord : `cd frontend && npx vitest run` pour voir l'état réel.\n\nLes 4 échecs rapportés :\n1. `mock.test.ts > exposes all thirteen gateways` — le mock expose désormais une gateway de plus (permissions ⇒ 14). Mets à jour le test ET vérifie que le mock (`frontend/src/adapters/mock/index.ts`) expose bien une gateway permissions cohérente avec le port réel (`TauriPermissionGateway`). Le titre du test (« thirteen ») et le compte attendu doivent refléter le vrai nombre de gateways.\n2. `LayoutGrid.chat.test.tsx` (2 échecs) — routage cellule terminal/chat.\n3. `agentAlreadyRunning.test.tsx` (1 échec) — notice R0d.\n\nPOUR CHAQUE échec : déterminer si c'est (a) un test à mettre à jour parce que le comportement/contrat a légitimement changé avec le feature permissions, ou (b) une vraie régression du code à corriger. Explique ton verdict pour chacun. Ne « fais pas passer » un test en masquant un vrai bug — si 2 et 3 ne sont pas liés aux permissions et révèlent une régression réelle, dis-le clairement plutôt que de les bricoler.\n\nVérifie aussi qu'il n'y a pas d'incohérence entre le port permissions (`frontend/src/ports/index.ts`), l'adapter Tauri (`frontend/src/adapters/permission.ts`), le mock, et les types domaine (`frontend/src/domain/index.ts`) — les 4 doivent être alignés.\n\nQuand c'est fini, réponds via idea_reply : verdict par échec (test à jour vs régression corrigée), ce que tu as changé, et la SORTIE RÉELLE de `npx vitest run` (doit être 100% vert). Si un échec est une vraie régression hors de ton périmètre permissions, signale-le précisément."}
|
||||||
|
{"id":"2a7f8f6d-3163-496a-a5b8-764a4229a40f","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781544382139,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[Test de communication inter-agent] Ping. Réponds simplement via idea_reply avec « pong DevFrontend OK » pour confirmer que le canal fonctionne."}
|
||||||
|
{"id":"6a286e41-aa26-4bcb-8530-282bd09dde95","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781544388479,"source":{"kind":"agent","agentId":"af7f86da-76bc-48e1-9900-71f45a624800"},"role":"response","text":"pong DevFrontend OK"}
|
||||||
|
{"id":"808e5ed2-57d7-4839-906e-bd66886852b7","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781593284048,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tâche : nettoyage/correction de bugs d'affichage UI (frontend React/TS). Project root : /home/anthony/Documents/Projects/IdeA. Aucune nouvelle feature, uniquement du fix CSS/layout. Voici 3 problèmes constatés visuellement (captures non transmissibles, je les décris) :\n\nPROBLÈME 1 — Barre de navigation horizontale (top bar listant : Projects, Context, Agents, Templates, Skills, Permissions, …).\n- Cette barre n'est PAS scrollable et n'est PAS correctement mise à l'échelle. Quand il y a trop d'entrées, les derniers items débordent et sont coupés (on voit « Skills » puis « P… » tronqué, « Permissions » coupé).\n- Attendu : la barre doit gérer le débordement proprement — soit scroll horizontal (overflow-x:auto avec masquage de scrollbar propre), soit wrap/scale correct. Les entrées ne doivent jamais être croppées. Vérifier flex-shrink/min-width des items.\n\nPROBLÈME 2 — Panneau « Agents » (liste des agents : Main, Architect, DevBackend, DevFrontend, QA, TestConversation, …).\n- Les items de la liste s'overlappent (chevauchement vertical) et sont croppés. Le nom de l'agent, le profil (« Claude Code »), l'id de run (longue chaîne en bleu qui wrap mal sur plusieurs lignes), la dropdown de profil et les boutons Stop/Delete/Launch se chevauchent et débordent du panneau.\n- Attendu : chaque carte d'agent doit avoir une hauteur qui s'adapte à son contenu (pas de height fixe qui cause l'overlap), les éléments alignés proprement, l'id long tronqué avec ellipsis ou wrap contrôlé, et la dropdown + boutons ne doivent pas déborder/chevaucher le texte voisin. Vérifier les position absolute éventuels et les gaps flex/grid.\n\nPROBLÈME 3 — Formulaire de création d'agent (champs : Name, Template, Profile, bouton Create).\n- Les champs du formulaire s'overlappent les uns sur les autres (Name / Template / Profile trop serrés/superposés).\n- BUG fonctionnel : quand on sélectionne une valeur dans les dropdowns (Template / Profile), la sélection NE s'affiche PAS dans le champ de la droplist (le label sélectionné ne se reflète pas visuellement). À investiguer : binding value/onChange du composant select, ou affichage du label sélectionné si c'est un dropdown custom.\n- Attendu : formulaire avec espacement correct (pas de chevauchement), et les dropdowns affichent bien la valeur choisie.\n\nMerci de :\n1. Localiser les composants concernés (top nav bar, panneau Agents, formulaire création d'agent).\n2. Corriger le CSS/layout et le bug de binding des dropdowns.\n3. Lancer un build frontend pour t'assurer que ça compile (npm run build ou équivalent), sans casser le reste.\n4. Me renvoyer via idea_reply la liste des fichiers modifiés + un résumé des corrections, pour que je puisse enchaîner sur le rebuild de l'AppImage."}
|
||||||
|
{"id":"a6169530-fc73-4057-91c6-900da4a611fb","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781593486944,"source":{"kind":"agent","agentId":"af7f86da-76bc-48e1-9900-71f45a624800"},"role":"response","text":"Bugs d'affichage corrigés. Build vert (`npm run build` = tsc --noEmit + vite build OK) et tests verts (`vitest run` agents + projects : 47/47 passent).\n\n## Fichiers modifiés (2)\n- `frontend/src/features/projects/ProjectsView.tsx` — la « top bar »\n- `frontend/src/features/agents/AgentsPanel.tsx` — panneau Agents + formulaire de création\n\n## Diagnostic\nLa « top bar » décrite n'est pas une nav OS mais le **strip de tabs du sidebar** (`SIDEBAR_TABS` : Projects, Context, Agents, Templates, Skills, Perms, Memory, Git) dans `ProjectsView.tsx`. Cause racine commune aux 3 problèmes : **largeur trop contrainte** dans le sidebar de 320px (`w-80`).\n\n## Corrections\n\n**PROBLÈME 1 — barre de tabs croppée**\n- Avant : 8 boutons en `flex-1` sans gestion de débordement → les derniers labels coupés.\n- Après : les tabs sont dans un conteneur `overflow-x-auto` **scrollable horizontalement**, chaque tab passe en `shrink-0 whitespace-nowrap` (plus jamais croppé). Scrollbar masquée proprement (`[scrollbar-width:none]` + `[&::-webkit-scrollbar]:hidden`). Le bouton « collapse » («) reste épinglé à droite, hors zone de scroll.\n\n**PROBLÈME 2 — cartes d'agents qui se chevauchent**\n- Avant : `<li>` en `flex items-center justify-between` → le bloc nom/profil et la colonne de contrôles (dropdown profil + Sync/Stop/Launch/Delete, en `shrink-0`) se disputaient la largeur ; l'id de run long (`running in IdeA · <id>`) wrappait et débordait.\n- Après : carte en **`flex-col`** — bloc nom/profil/statut en haut, rangée de contrôles en dessous (`flex-wrap`, plus de `shrink-0` qui écrasait le texte). Hauteur adaptative au contenu (plus de chevauchement). L'id long est désormais **tronqué avec ellipsis** (`block w-full min-w-0 truncate`).\n\n**PROBLÈME 3 — formulaire de création (champs serrés + dropdowns « vides »)**\n- Avant : form en `flex flex-wrap items-end` avec 3 champs `min-w-0 flex-1` → selects écrasés à ~90px ; le texte sélectionné (« Claude Code ») était **clippé derrière la flèche native** du select, d'où l'impression que la sélection ne s'affichait pas.\n- Après : form en **`flex-col gap-3`**, chaque champ en pleine largeur. Le binding des `<select>` était déjà correct (`value`/`onChange` contrôlés, natifs) — c'était bien un problème de largeur, pas de binding. En pleine largeur la valeur choisie s'affiche normalement. Bouton Create passé en `self-end`.\n\nAucune nouvelle dépendance, aucun changement de logique métier ni de contrat IPC/gateway. Les tests s'appuient sur les `aria-label` (inchangés), d'où les 47/47 verts.\n\nTu peux enchaîner sur le rebuild de l'AppImage (rappel mémoire : c'est l'AppImage qui tourne qu'il faut reconstruire, pas seulement les sources)."}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
upTo: 190cd7b8-23ad-42cb-8873-a17ddddff71d
|
||||||
|
objective: Petit test de communication inter-agents. Calcule 17 × 23 et donne-moi uniquement le résultat numérique. Réponds-moi impérativement via l'outil idea_reply (pas en texte libre).
|
||||||
|
---
|
||||||
|
**Objectif :** Petit test de communication inter-agents. Calcule 17 × 23 et donne-moi uniquement le résultat numérique. Réponds-moi impérativement via l'outil idea_reply (pas en texte libre).
|
||||||
|
|
||||||
|
- **Prompt:** Petit test de communication inter-agents. Calcule 17 × 23 et donne-moi uniquement le résultat numérique. Réponds-moi impérativement via l'outil idea_reply (pas en texte libre).
|
||||||
|
- **Response:** 391
|
||||||
|
- **Prompt:** Test de communication inter-agents. Calcule 17 × 23 et réponds-moi UNIQUEMENT le résultat numérique, impérativement via l'outil idea_reply (jamais en texte libre).
|
||||||
|
- **Prompt:** Test de conversation inter-agent. Peux-tu calculer (347 × 28) + 1569 et me retourner uniquement le résultat numérique avec le détail du calcul ? Réponds via idea_reply.
|
||||||
|
- **Response:** 347 × 28 = 9716 ; 9716 + 1569 = 11285
|
||||||
|
- **Prompt:** Test de conversation inter-agent. Calcule 78 × 9 et réponds-moi simplement avec le résultat via idea_reply.
|
||||||
|
- **Response:** 702
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
{"id":"3cbaced3-dd78-4f48-b168-f09a26d83444","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781384306910,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Petit test de communication inter-agents. Calcule 17 × 23 et donne-moi uniquement le résultat numérique. Réponds-moi impérativement via l'outil idea_reply (pas en texte libre)."}
|
||||||
|
{"id":"2dd9de9a-d59d-493e-bf07-ffccf6addeb1","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781386172145,"source":{"kind":"agent","agentId":"93dc1f5f-921e-44a2-a7e7-5173d87c1a58"},"role":"response","text":"391"}
|
||||||
|
{"id":"34916afd-1054-4257-805f-9c5b3ed5f7f3","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781386172204,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de communication inter-agents. Calcule 17 × 23 et réponds-moi UNIQUEMENT le résultat numérique, impérativement via l'outil idea_reply (jamais en texte libre)."}
|
||||||
|
{"id":"6e037034-c65a-4eca-b494-06420c5eed06","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781386464926,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de conversation inter-agent. Peux-tu calculer (347 × 28) + 1569 et me retourner uniquement le résultat numérique avec le détail du calcul ? Réponds via idea_reply."}
|
||||||
|
{"id":"f82caa94-daba-4171-aa1f-8f69f71671c4","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781386489560,"source":{"kind":"agent","agentId":"93dc1f5f-921e-44a2-a7e7-5173d87c1a58"},"role":"response","text":"347 × 28 = 9716 ; 9716 + 1569 = 11285"}
|
||||||
|
{"id":"6187c292-4d9a-4c07-9381-732486ab6d03","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781522784017,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de conversation inter-agent. Calcule 78 × 9 et réponds-moi simplement avec le résultat via idea_reply."}
|
||||||
|
{"id":"190cd7b8-23ad-42cb-8873-a17ddddff71d","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781522789866,"source":{"kind":"agent","agentId":"93dc1f5f-921e-44a2-a7e7-5173d87c1a58"},"role":"response","text":"702"}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
upTo: 419c60a5-2aca-4711-ad83-0a185bb3214e
|
||||||
|
objective: Ping de test inter-agent depuis DevBackend — réponds avec un court PONG et l'heure que tu vois.
|
||||||
|
---
|
||||||
|
**Objectif :** Ping de test inter-agent depuis DevBackend — réponds avec un court PONG et l'heure que tu vois.
|
||||||
|
|
||||||
|
- **Prompt:** Ping de test inter-agent depuis DevBackend — réponds avec un court PONG et l'heure que tu vois.
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{"id":"419c60a5-2aca-4711-ad83-0a185bb3214e","conversation":"dd35eeb0-2329-0dfa-3255-df7dba90a0aa","atMs":1781427081894,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"prompt","text":"Ping de test inter-agent depuis DevBackend — réponds avec un court PONG et l'heure que tu vois."}
|
||||||
@ -1,24 +1,24 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"activeId": "1af250f0-65ef-4b78-8905-b1746673aee0",
|
"activeId": "dd38b8ed-7199-48a6-a63f-1d1462ca6a34",
|
||||||
"layouts": [
|
"layouts": [
|
||||||
{
|
{
|
||||||
"id": "1af250f0-65ef-4b78-8905-b1746673aee0",
|
"id": "dd38b8ed-7199-48a6-a63f-1d1462ca6a34",
|
||||||
"name": "Default",
|
"name": "Default",
|
||||||
"kind": "terminal",
|
"kind": "terminal",
|
||||||
"tree": {
|
"tree": {
|
||||||
"root": {
|
"root": {
|
||||||
"type": "split",
|
"type": "split",
|
||||||
"node": {
|
"node": {
|
||||||
"id": "56ffe1e4-636c-458d-9ab2-7e278fd45897",
|
"id": "2ea41e76-262d-4dc3-a674-348ceac9b543",
|
||||||
"direction": "row",
|
"direction": "row",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"node": {
|
"node": {
|
||||||
"type": "leaf",
|
"type": "leaf",
|
||||||
"node": {
|
"node": {
|
||||||
"id": "c3319a9a-1345-4fa2-b64e-5f3fe00d13d8",
|
"id": "14d20dcb-f243-4381-a130-2d4d3d224f42",
|
||||||
"session": "4965c71a-f69f-4c06-90de-ecb81acff710",
|
"session": "4e60d6c2-6be6-499c-a296-577ce1e5ec62",
|
||||||
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641",
|
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641",
|
||||||
"agentWasRunning": true
|
"agentWasRunning": true
|
||||||
}
|
}
|
||||||
@ -27,36 +27,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"node": {
|
"node": {
|
||||||
"type": "split",
|
"type": "leaf",
|
||||||
"node": {
|
"node": {
|
||||||
"id": "8cf1c06e-2654-45a3-bf17-a9c2507da935",
|
"id": "71f5cc81-9bef-4b2b-b7dc-2e0a5a4eafcb",
|
||||||
"direction": "column",
|
"session": "bb08a08e-71f3-4ab2-842c-71a8da5c74b4",
|
||||||
"children": [
|
"agent": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"
|
||||||
{
|
|
||||||
"node": {
|
|
||||||
"type": "leaf",
|
|
||||||
"node": {
|
|
||||||
"id": "69dc2e23-86f5-4770-84c4-b1b4b2c25299",
|
|
||||||
"session": "11fbf6b4-eb62-4420-95e9-feb2ff667c43",
|
|
||||||
"agent": "c932c770-cf36-4fb2-a966-71bb1644e4b4",
|
|
||||||
"agentWasRunning": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"node": {
|
|
||||||
"type": "leaf",
|
|
||||||
"node": {
|
|
||||||
"id": "b9251e74-3bd5-43ee-90e5-e6bb87faab38",
|
|
||||||
"session": "69a3bf52-05ef-45c0-badf-26b0d8224f0e",
|
|
||||||
"agent": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
|
|
||||||
"agentWasRunning": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"weight": 1.0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"weight": 1.0
|
"weight": 1.0
|
||||||
|
|||||||
@ -4,3 +4,5 @@
|
|||||||
- [idea-product-directives-main-handoff](idea-product-directives-main-handoff.md) — Directives produit consolidees pour guider Main sur la robustesse, la persistance, le handoff cross-profile et la sobriete UX.
|
- [idea-product-directives-main-handoff](idea-product-directives-main-handoff.md) — Directives produit consolidees pour guider Main sur la robustesse, la persistance, le handoff cross-profile et la sobriete UX.
|
||||||
- [remaining-work-idea-agent-control-ide](remaining-work-idea-agent-control-ide.md) — Etat des lieux des acquis et des chantiers restants pour aligner IdeA avec la cible d'IDE de controle d'agents IA.
|
- [remaining-work-idea-agent-control-ide](remaining-work-idea-agent-control-ide.md) — Etat des lieux des acquis et des chantiers restants pour aligner IdeA avec la cible d'IDE de controle d'agents IA.
|
||||||
- [mcp-bridge-and-delegation-runtime-notes](mcp-bridge-and-delegation-runtime-notes.md) — Pieges runtime du pont MCP/delegation et regle de rebuild de l'AppImage (binaire qui tourne = AppImage, pas les sources).
|
- [mcp-bridge-and-delegation-runtime-notes](mcp-bridge-and-delegation-runtime-notes.md) — Pieges runtime du pont MCP/delegation et regle de rebuild de l'AppImage (binaire qui tourne = AppImage, pas les sources).
|
||||||
|
- [permissions-sandbox-system-state](permissions-sandbox-system-state.md) — Systeme de permissions/sandbox complet (Landlock sur PTY + structure) et le risque residuel $HOME/resume du chemin structure.
|
||||||
|
- [session-limit-handling-design](session-limit-handling-design.md) — Design valide (detecteur hierarchique + reprise auto annulable) pour les limites de session des agents.
|
||||||
|
|||||||
26
.ideai/memory/permissions-sandbox-system-state.md
Normal file
26
.ideai/memory/permissions-sandbox-system-state.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: permissions-sandbox-system-state
|
||||||
|
description: Etat du systeme de permissions/sandbox IdeA (domaine pur -> projection advisory -> enforcement OS Landlock sur PTY ET structure) et l'unique risque residuel.
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
# Systeme de permissions / sandbox IdeA
|
||||||
|
|
||||||
|
Chantier "permissions des agents" complet bout-en-bout au 2026-06-16, sur la branche `wip/p8c-checkpoint-before-codex`. Lots committes : LP4-0 (`b05d04a`), LP4-1/LP4-2 (`17ca65e`), LP4-3 (`7e01ac6`), LP4-4 (`6236cd7`).
|
||||||
|
|
||||||
|
## Acquis (tout vert, ~80 suites)
|
||||||
|
|
||||||
|
- **Domaine pur** (`crates/domain/src/permission.rs`, `sandbox.rs`) : modele declaratif (`Capability`, `Effect`, `Posture` Allow<Ask<Deny, deny-wins), `resolve()` (invariant produit : rien pose => `None` => CLI garde son prompting natif), `compile_sandbox_plan(eff, ctx) -> Option<SandboxPlan>`, port `SandboxEnforcer`, `render_permission_summary` (honnetete : fichiers = OS-enforced, commandes = advisory).
|
||||||
|
- **Store** : `.ideai/permissions.json` (separe d'`agents.json` : securite projet, pas identite).
|
||||||
|
- **Projection advisory LP3** : projecteurs Claude (`settings.json`) + Codex (sandbox/`config.toml`).
|
||||||
|
- **Enforcement OS LP4** : `LandlockSandbox` (Linux) / `NoopSandbox` / `default_enforcer()`. Actif sur **les deux chemins** : PTY brut (`pty/mod.rs` `spawn_command_sandboxed`) ET structure Claude/Codex JSON (`session/process.rs` `run_turn_sandboxed`).
|
||||||
|
|
||||||
|
## Technique d'enforcement (load-bearing, ne pas casser)
|
||||||
|
|
||||||
|
`enforce(plan)` tourne sur un **thread jetable AVANT le spawn**, puis le child est spawne depuis ce thread => il herite le domaine Landlock via les credentials de la tache (garanti par le noyau a travers fork/clone/execve, y compris posix_spawn). **Pas de `pre_exec`** : `infrastructure` est `#![forbid(unsafe_code)]` (intact), et le pre_exec n'aurait ete qu'une assurance de determinisme, sans valeur de securite ; allouer dans `landlock` post-fork (pre_exec) serait au contraire dangereux (deadlock malloc en process multithreade). Fail-closed : `Err` d'enforce => aucun child. Le garde-fou anti-regression est le test e2e **"ecriture hors-grant bloquee"** (present pour PTY et structure) : tant qu'il est vert, l'heritage tient.
|
||||||
|
|
||||||
|
## Risque residuel a traiter (signale par l'Architecte, NON couvert)
|
||||||
|
|
||||||
|
Les CLI structurees (claude/codex = binaires Node) ont des besoins FS ambiants lourds : `~/.claude`/`~/.codex` (credentials + cache de session pour le **resume**), `node_modules`, libs systeme, caches temp. `compile_sandbox_plan` ne fence que les classes explicitement posees et garde les lectures ouvertes — mais une policy posant un `Deny`/posture restrictive touchant `$HOME` **peut casser le resume**. Le mecanisme est valide au fake CLI (zero token), mais **avant d'activer un plan restrictif en prod sur le chemin structure**, valider e2e manuellement qu'un vrai tour claude/codex sous plan representatif garde run_dir + home de la CLI atteignables. C'est un sujet de **composition du plan** (run_dir reachability ; `SandboxContext.run_dir` existe mais n'est pas encore consomme par la traduction pure), pas du mecanisme — lot suivant si besoin.
|
||||||
|
|
||||||
|
Voir [[remaining-work-idea-agent-control-ide]] pour le reste des chantiers produit.
|
||||||
26
.ideai/memory/session-limit-handling-design.md
Normal file
26
.ideai/memory/session-limit-handling-design.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: session-limit-handling-design
|
||||||
|
description: Design valide pour la detection des limites de session des agents et la reprise auto a la levee.
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
Feature : detecter quand un agent IA est en limite de session (et jusqu'a quelle heure), puis reprendre ou il en etait une fois la limite levee. Cadree le 2026-06-16.
|
||||||
|
|
||||||
|
Constat dur : l'heure exacte de reset n'existe nulle part de facon universelle (ni OS, ni code de sortie, ni API inter-modeles) — elle est fabriquee par le fournisseur et seulement exposee dans le flux de sa CLI. Donc « 100% fiable + zero dependance modele + heure exacte » sont incompatibles simultanement ; on vise le meilleur compromis via un detecteur hierarchique.
|
||||||
|
|
||||||
|
Solution retenue — detecteur hierarchique calque sur la hierarchie de readiness existante ([[remaining-work-idea-agent-control-ide]]) :
|
||||||
|
- Niveau 1 (solide) : l'adapter structure extrait limite + reset du flux machine. Pour Claude, `rate_limit_event.rate_limit_info` est DEJA parse dans `infrastructure/session/claude.rs` mais jete (reduit a Heartbeat) — il suffit de lire `resetsAt`.
|
||||||
|
- Niveau 2 (configurable) : champ de profil `rate_limit_pattern` (regex + capture heure) pour agents PTY/TUI sans adapter structure, dans la lignee des profils declaratifs §9.
|
||||||
|
- Niveau 3 (filet humain) : si rien ne matche mais agent `Stalled` (deja prevu dans `domain/readiness.rs`), IdeA DEMANDE a l'utilisateur. Garantit le « 100% meme pour un novice » : jamais d'inaction silencieuse.
|
||||||
|
|
||||||
|
Model-agnostic tenu AU DOMAINE : le domaine ne connait que `RateLimited { until: Option<Instant> }`. Le savoir specifique modele est confine aux adapters/profils (philosophie §9).
|
||||||
|
|
||||||
|
Reprise = partie facile et deja model-agnostique : pivot sur `conversation_id` du moteur + `--resume` natif (deja cable dans `session/claude.rs` + `agent/resume.rs`) qui portent tout l'historique. Pas de reconstruction manuelle fragile.
|
||||||
|
|
||||||
|
Decisions produit verrouillees (2026-06-16) :
|
||||||
|
- Reprise : AUTOMATIQUE a l'heure de reset, ANNULABLE (fenetre + notification).
|
||||||
|
- Couverture : les TROIS niveaux d'emblee (incl. repli regex niveau 2).
|
||||||
|
- Etat : EN MEMOIRE uniquement (pas de persistance de SessionLimit). Consequence assumee : le reveil auto ne joue que tant qu'IdeA reste ouvert ; si l'IDE est ferme/rouvert apres le reset, le chemin existant `ListResumableAgents` (agent_was_running/conversation_id) prend le relais.
|
||||||
|
|
||||||
|
Decoupage (cycle dev/test §3) : 1) domaine (variante `ReplyEvent::RateLimited`, `ReadinessSignal::RateLimited`, etat `SessionLimit`) ; 2) adapter Claude (extraire resetsAt) ; 3) profil (`rate_limit_pattern`) ; 4) application (planificateur de reprise sur le port Clock) ; 5) UI (badge « limite jusqu'a HH:MM » + filet humain).
|
||||||
38
.ideai/permissions.json
Normal file
38
.ideai/permissions.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"projectDefaults": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"capability": "read",
|
||||||
|
"effect": "allow",
|
||||||
|
"paths": [
|
||||||
|
"**"
|
||||||
|
],
|
||||||
|
"commands": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability": "write",
|
||||||
|
"effect": "allow",
|
||||||
|
"paths": [
|
||||||
|
"**"
|
||||||
|
],
|
||||||
|
"commands": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability": "delete",
|
||||||
|
"effect": "allow",
|
||||||
|
"paths": [
|
||||||
|
"**"
|
||||||
|
],
|
||||||
|
"commands": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability": "executeBash",
|
||||||
|
"effect": "allow",
|
||||||
|
"paths": [],
|
||||||
|
"commands": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fallback": "allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
185
ARCHITECTURE.md
185
ARCHITECTURE.md
@ -2160,4 +2160,189 @@ La décision frontière l'évite : **la frontière propre est jugée côté fron
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 21. Gestion des limites de session des agents — détection hiérarchique + reprise auto annulable (cadrage 2026-06-16)
|
||||||
|
|
||||||
|
> Cadrage produit verrouillé avec l'utilisateur le 2026-06-16. **Étape 1 du cycle §3 — AUCUN code.**
|
||||||
|
> Besoin : IdeA doit savoir **quand** un agent est en limite de session **et jusqu'à quand**, puis lui
|
||||||
|
> demander de **reprendre où il en était** une fois la limite levée. Priorités : (1) **SOLIDE** (marche
|
||||||
|
> même pour un novice, ~100 % des cas) ; (2) **sans dépendance au modèle** autant que possible.
|
||||||
|
|
||||||
|
### 21.0 État du terrain (lu dans le code, pas présumé)
|
||||||
|
|
||||||
|
- `domain/src/ports.rs` — `ReplyEvent` = `{ TextDelta, ToolActivity, Heartbeat, Final }`. **Aujourd'hui un
|
||||||
|
`rate_limit_event` Claude est réduit à `Heartbeat`** (`infrastructure/src/session/claude.rs:90`,
|
||||||
|
commentaire « fenêtre de limite de débit ») : l'info `rate_limit_info.resetsAt` est **lue puis jetée**.
|
||||||
|
- `domain/src/readiness.rs` — `ReadinessSignal` = `{ TurnEnded, ExplicitReply, PromptReady, Stalled, TimedOut }`.
|
||||||
|
`Stalled`/`TimedOut` sont **réservés au lot 2** (vocabulaire présent, **non produits** par `classify`).
|
||||||
|
- `domain/src/input.rs` — deux axes d'état **orthogonaux** déjà posés : `AgentBusyState{Idle,Busy}` et
|
||||||
|
`AgentLiveness{Alive,Stalled}`. La limite de session sera un **3ᵉ axe orthogonal**.
|
||||||
|
- Reprise déjà câblée et **model-agnostique** : pivot `conversation_id` du moteur (`LeafCell.conversation_id`)
|
||||||
|
+ `SessionPlan::{None,Assign,Resume}` (`ports.rs`) + `build_spawn_line` (`session/claude.rs:200`, `--resume`)
|
||||||
|
+ `ListResumableAgents` (`application/agent/resume.rs`) + `SessionInspector` (`inspector/claude.rs`, « dernier sujet »).
|
||||||
|
- `domain/src/ports.rs` — `Clock::now_millis() -> i64` (**épochе-millis**). **Il n'existe AUCUN port de
|
||||||
|
minuterie** (pas de « réveille-moi à T »). C'est le seul vrai manque.
|
||||||
|
|
||||||
|
### 21.1 Décisions d'architecture (tranchées)
|
||||||
|
|
||||||
|
1. **Détecteur HIÉRARCHIQUE** calqué sur la hiérarchie de readiness existante. Trois niveaux, *premier
|
||||||
|
qui matche gagne*, jamais d'inaction silencieuse :
|
||||||
|
- **Niveau 1 — structuré (solide)** : l'adapter structuré extrait limite + reset du flux machine. Pour
|
||||||
|
Claude, lire `rate_limit_info.resetsAt` **au lieu de le jeter**.
|
||||||
|
- **Niveau 2 — déclaratif (configurable)** : champ de profil `rate_limit_pattern` (motif + capture de
|
||||||
|
l'heure) pour agents **PTY/TUI sans adapter structuré**, dans la lignée des profils déclaratifs §9.
|
||||||
|
- **Niveau 3 — filet humain** : rien ne matche **mais** l'agent est `Stalled` (lot 2) ⇒ IdeA **demande**
|
||||||
|
à l'utilisateur. Garantit le « 100 % même pour un novice » : jamais d'inaction silencieuse.
|
||||||
|
2. **Model-agnostic tenu AU DOMAINE.** Le domaine ne connaît qu'un fait neutre : « limité, reset à T (peut-
|
||||||
|
être) ». Tout savoir spécifique modèle (forme du `rate_limit_event`, regex d'une TUI, parsing d'une heure
|
||||||
|
locale) reste **confiné aux adapters/profils** (philosophie §9).
|
||||||
|
3. **État EN MÉMOIRE uniquement** (décidé). **Aucune persistance** de `SessionLimit`, **aucun nouveau store**,
|
||||||
|
**aucun schéma `.ideai/` modifié**. Conséquence assumée : le réveil auto ne joue que tant qu'IdeA reste
|
||||||
|
ouvert ; IDE fermé/rouvert après le reset ⇒ le chemin **existant** `ListResumableAgents`
|
||||||
|
(`agent_was_running`/`conversation_id`) prend le relais (popup de reprise, pas d'auto).
|
||||||
|
4. **Reprise AUTOMATIQUE à l'heure de reset, ANNULABLE** (fenêtre + notification UI). Réutilise
|
||||||
|
`SessionPlan::Resume` + un **prompt de reprise court** ; `--resume` porte tout l'historique (zéro
|
||||||
|
reconstruction manuelle).
|
||||||
|
5. **Un seul nouveau port** : `Scheduler` (minuterie one-shot annulable). Tout le reste **réutilise**
|
||||||
|
l'existant (`Clock`, `EventBus`, `AgentSession`/`Factory`, `LaunchAgent`, `InputMediator`).
|
||||||
|
|
||||||
|
### 21.2 Tensions avec l'archi hexagonale existante — et leur résolution (à lire avant de coder)
|
||||||
|
|
||||||
|
| # | Tension (proposition initiale) | Résolution retenue |
|
||||||
|
|---|---|---|
|
||||||
|
| **T1** | `RateLimited { until: Option<Instant> }`. `std::time::Instant` est **monotone, non sérialisable, non horloge murale**, et **absent du domaine** (qui parle `i64` époche-millis via `Clock`). | **Abandonner `Instant`** → `resets_at_ms: Option<i64>` (époche-millis), homogène avec `Clock::now_millis` et `AgentBusyState.since_ms`, serde-friendly. **Déviation assumée de la proposition.** |
|
||||||
|
| **T2** | `rate_limit_pattern` = **regex + capture**. Or le domaine est **dépendance-zéro** ; `prompt_ready_pattern` a été délibérément choisi **littéral** pour éviter la dépendance `regex`. | Le **domaine ne stocke que de la donnée** : `RateLimitPattern { pattern, reset_capture, time_format }` (chaînes). Le **moteur regex + le parsing d'heure vivent en infra** (un composant `RateLimitParser` / le watcher PTY). `regex` est ajouté au `Cargo.toml` de **`infrastructure` uniquement**. Domaine pur préservé (§1.4 / §9). |
|
||||||
|
| **T3** | « armer un réveil sur le port `Clock` ». `Clock` ne sait que **donner l'heure**, pas **réveiller**. | Nouveau port **`Scheduler`** (ISP : minuterie fine, une responsabilité). `Clock` reste pour « maintenant ». |
|
||||||
|
| **T4** | `RateLimited` comme événement de tour. Le contrat `ReplyStream` dit **« seul `Final` est terminal »** et `claude.rs` **rompt** la boucle au `Final`. | `ReplyEvent::RateLimited` est **non terminal** (comme `Heartbeat`) : il s'intercale, le flux continue jusqu'à `Final` **ou** clôture. **Point d'intégration** : un tour clos **sans `Final`** *parce que* limité ne doit **pas** devenir `AgentSessionError::Io` (« flux clos sans Final ») — `drain_bounded` doit traiter « clos + `RateLimited` vu » comme une **fin gracieuse limitée**, pas une erreur. |
|
||||||
|
| **T5** | Niveau 3 « si agent `Stalled` ». Or `Stalled` est **réservé** au lot 2 (non produit aujourd'hui). | Le **niveau 3 dépend du lot 2** (détection de stagnation). À livrer **après** ; d'ici là, niveaux 1+2 couvrent le structuré et le PTY configuré. **Dépendance explicitée** dans le découpage. |
|
||||||
|
|
||||||
|
### 21.3 Modèle de domaine (ajouts purs, I/O-free)
|
||||||
|
|
||||||
|
- **`ReplyEvent::RateLimited { resets_at_ms: Option<i64> }`** (`ports.rs`) — non terminal, model-agnostique.
|
||||||
|
- **`ReadinessSignal::RateLimited { resets_at_ms: Option<i64> }`** (`readiness.rs`) — l'enum **reste `Copy`**
|
||||||
|
(`Option<i64>` est `Copy`). `ReadinessPolicy::classify(ReplyEvent::RateLimited{..})` ⇒
|
||||||
|
`Some(ReadinessSignal::RateLimited{..})` (seul ajout au `match`).
|
||||||
|
- **`domain/src/session_limit.rs` (NOUVEAU)** :
|
||||||
|
- `SessionLimit { resets_at_ms: Option<i64>, detected_at_ms: i64, source: RateLimitSource }` (VO).
|
||||||
|
- `RateLimitSource { Structured, Pattern, Human }` (traçabilité du niveau, pour l'UI).
|
||||||
|
- **fonction pure** `plan_resume(now_ms, &SessionLimit) -> ResumePlan` avec
|
||||||
|
`ResumePlan { fire_at_ms: i64 }` (si `resets_at_ms` absent ⇒ pas de plan auto ⇒ filet humain).
|
||||||
|
Toute la logique de calendrier est **pure et testable sans I/O** (cf. `LayoutTree`, §7.2).
|
||||||
|
- **`AgentProfile.rate_limit_pattern: Option<RateLimitPattern>`** + `with_rate_limit_pattern` (builder),
|
||||||
|
`#[serde(default, skip_serializing_if = "Option::is_none")]` ⇒ **zéro régression** de sérialisation
|
||||||
|
(mêmes tests que `liveness`/`prompt_ready_pattern`). `RateLimitPattern` = **donnée** (cf. T2), validée a
|
||||||
|
minima (motif non vide) par un constructeur *parse-don't-validate*.
|
||||||
|
- **`events.rs`** : `AgentRateLimited { agent_id, resets_at_ms: Option<i64> }`,
|
||||||
|
`AgentResumeScheduled { agent_id, fire_at_ms }`, `AgentResumeCancelled { agent_id }`,
|
||||||
|
`AgentResumed { agent_id }`, `AgentRateLimitSuspected { agent_id }` (niveau 3). Discrets, basse fréquence.
|
||||||
|
|
||||||
|
### 21.4 Le port `Scheduler` (frontière domaine — le seul nouveau port)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Minuterie one-shot **annulable** (ARCHITECTURE §21). `Clock` dit l'heure ; ce
|
||||||
|
/// port *réveille* à une échéance absolue. In-memory (aucune persistance, §21.1-3).
|
||||||
|
pub trait Scheduler: Send + Sync {
|
||||||
|
/// Arme un réveil à `deadline_ms` (époche-ms) qui, à échéance, **pousse** `task`
|
||||||
|
/// vers le drain applicatif. Renvoie un id annulable.
|
||||||
|
fn arm(&self, deadline_ms: i64, task: ScheduledTask) -> ScheduleId;
|
||||||
|
/// Annule un réveil armé (idempotent ; `false` si déjà tiré/inconnu).
|
||||||
|
fn cancel(&self, id: ScheduleId) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Intention model-agnostique exécutée à l'échéance (donnée pure, pas de closure
|
||||||
|
/// traversant la frontière). Calqué sur l'esprit du dispatch orchestrateur §14.3.
|
||||||
|
pub enum ScheduledTask {
|
||||||
|
ResumeAgent { agent_id: AgentId, node_id: NodeId, conversation_id: Option<String> },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Pourquoi un port plutôt qu'un `tokio::sleep` direct** : garder l'**application testable sans temps réel**
|
||||||
|
(un `Scheduler` fake déclenche à la demande) et l'inversion de dépendance (§1.2-D). `Clock` **reste** le
|
||||||
|
port d'« heure courante ».
|
||||||
|
- **Adapter** : `TokioScheduler` (`infrastructure/src/scheduler/`) — `tokio::time::sleep_until` + table de
|
||||||
|
`JoinHandle` (abort = `cancel`) ; à l'échéance il **pousse `task`** dans un `mpsc` fourni à la construction
|
||||||
|
(le drain est côté application). **Direction des dépendances respectée** : infra → canal → application
|
||||||
|
(exactement le motif du watcher orchestrateur §14.3, qui draine un dispatch unique).
|
||||||
|
|
||||||
|
### 21.5 Application — service de limite & reprise
|
||||||
|
|
||||||
|
- **`application/src/agent/session_limit.rs` (NOUVEAU)** : `SessionLimitService`, qui compose **uniquement
|
||||||
|
des ports/use-cases existants** + `Scheduler` :
|
||||||
|
- `on_rate_limited(agent, SessionLimit)` : enregistre une entrée **en mémoire**, `plan_resume`, `Scheduler::
|
||||||
|
arm(fire_at_ms, ResumeAgent{..})`, publie `AgentRateLimited` + `AgentResumeScheduled`.
|
||||||
|
- `cancel_resume(agent)` : `Scheduler::cancel`, publie `AgentResumeCancelled` (la fenêtre annulable UI).
|
||||||
|
- `resume_now(agent)` / drain de `ScheduledTask::ResumeAgent` : **compose `LaunchAgent` / `AgentSessionFactory::
|
||||||
|
start` avec `SessionPlan::Resume{conversation_id}`** + **prompt de reprise court** ; publie `AgentResumed`.
|
||||||
|
- `confirm_manual(agent, resets_at_ms)` (niveau 3) : même chemin que niveau 1 avec `source = Human`.
|
||||||
|
- **`application/src/agent/structured.rs`** : dans `drain_with_readiness`, réagir à
|
||||||
|
`ReadinessSignal::RateLimited{resets_at_ms}` ⇒ `SessionLimitService::on_rate_limited`. **Réconcilier T4** :
|
||||||
|
un flux clos **sans `Final`** alors qu'un `RateLimited` a été vu ⇒ **pas** d'`Io`, mais une issue « limité »
|
||||||
|
(l'agent reste vivant ; le service arme la reprise). Le `mark_idle`/FIFO reste piloté par `Final`/timeout.
|
||||||
|
- **Niveau 3 (filet humain)** : à brancher **après le lot 2** — quand le détecteur de stagnation passe
|
||||||
|
`Alive→Stalled` et qu'**aucune** `SessionLimit` n'est connue pour l'agent, le service publie
|
||||||
|
`AgentRateLimitSuspected` ⇒ l'UI demande (jamais d'auto sans heure).
|
||||||
|
|
||||||
|
### 21.6 Infrastructure — où vit chaque détail modèle
|
||||||
|
|
||||||
|
- **Niveau 1** — `infrastructure/src/session/claude.rs` `parse_event` : `rate_limit_event` ⇒ lire
|
||||||
|
`rate_limit_info.resetsAt`, le **normaliser en époche-ms** (ISO-8601/époch → `i64`) et émettre
|
||||||
|
`ReplyEvent::RateLimited{resets_at_ms}` (non terminal) **au lieu** du `Heartbeat` actuel. Ajuster la boucle
|
||||||
|
`send` (le `break 'lines` reste sur `Final` ; `RateLimited` ne rompt pas). `session/codex.rs` : mapper
|
||||||
|
l'équivalent Codex **s'il existe**, sinon s'en remettre au niveau 2.
|
||||||
|
- **Niveau 2** — `infrastructure/src/ratelimit/` (NOUVEAU) `RateLimitParser` : compile le
|
||||||
|
`rate_limit_pattern` du profil (**`regex`, dépendance infra seulement**, cf. T2), observe le flux PTY du
|
||||||
|
handle lié (réutilise l'armement du watcher de prompt, `MediatedInbox`), et à un match calcule
|
||||||
|
`resets_at_ms` (capture d'heure + `Clock` + date du jour) puis émet `ReplyEvent::RateLimited` /
|
||||||
|
notifie le service. **Aucune** regex ne franchit la frontière domaine.
|
||||||
|
- **Port `Scheduler`** — `infrastructure/src/scheduler/` `TokioScheduler` (cf. §21.4).
|
||||||
|
- `infrastructure/src/clock/` — **inchangé** (réutilisé pour « maintenant » et le calcul d'heure de reset).
|
||||||
|
|
||||||
|
### 21.7 Présentation (app-tauri + frontend)
|
||||||
|
|
||||||
|
- **`app-tauri`** (composition root) : instancier `TokioScheduler` + `SessionLimitService`, démarrer le
|
||||||
|
**drain des `ScheduledTask`** (boucle de fond, jumelle du watcher orchestrateur). Commandes :
|
||||||
|
`cancel_agent_resume`, `resume_agent_now`, `confirm_agent_rate_limit{resets_at_ms}`. Relais des nouveaux
|
||||||
|
`DomainEvent` en events IPC **camelCase** (`agentRateLimited`, `agentResumeScheduled`, …).
|
||||||
|
- **`frontend`** (`features/agents` + `features/terminals`) : **badge « limité jusqu'à HH:MM »** + compte à
|
||||||
|
rebours + bouton **« Annuler la reprise »** (fenêtre annulable) ; **dialogue du filet humain** (niveau 3 :
|
||||||
|
« limite détectée mais heure inconnue — reprendre à ? »). Étendre un `gateway` UI (port TS) + son adapter
|
||||||
|
Tauri + les mocks (§1.3). Le « dernier sujet » du `SessionInspector` enrichit la notification.
|
||||||
|
|
||||||
|
### 21.8 Conformité hexagonale & SOLID
|
||||||
|
|
||||||
|
- **Domaine pur** : `SessionLimit`/`plan_resume`/`classify` testables **sans I/O ni temps réel** (T1 époche-ms,
|
||||||
|
T2 regex hors domaine). **S** : `SessionLimitService` = une intention (détecter→planifier→reprendre) ;
|
||||||
|
`Scheduler` = une responsabilité (minuter). **O** : ajouter un moteur niveau 1 = un adapter ; ajouter une
|
||||||
|
TUI niveau 2 = **de la donnée** (`rate_limit_pattern`), pas de code. **L** : tout `Scheduler` (réel/fake)
|
||||||
|
substituable. **I** : `Scheduler` minimal (`arm`/`cancel`), distinct de `Clock`. **D** : l'application
|
||||||
|
reçoit `Arc<dyn Scheduler>` injecté au composition root.
|
||||||
|
|
||||||
|
### 21.9 Découpage en LOTS testables (cycle dev↔QA §3) — ordonné
|
||||||
|
|
||||||
|
| Lot | Couche | Contenu | Vert quand |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **LS1** | domaine | `ReplyEvent::RateLimited` ; `ReadinessSignal::RateLimited` + `classify` ; `session_limit.rs` (VO + `plan_resume`) ; `events.rs` (5 variantes) ; `profile.rate_limit_pattern` + builder | tests purs : `classify` mappe RateLimited ; `plan_resume` (avec/sans `resets_at`) ; round-trip serde profil (clé omise si `None`, legacy→`None`) ; `ReadinessSignal` reste `Copy` |
|
||||||
|
| **LS2** | infra (niv. 1) | `claude.rs` `parse_event` extrait `resetsAt`→époche-ms→`RateLimited` ; boucle `send` (pas de rupture sur RateLimited) ; idem Codex si applicable | conformance : une ligne `rate_limit_event` ⇒ `RateLimited{Some(ms)}` ; un tour `rate_limit_event`+`result` ⇒ `[…,RateLimited,Final]` ; sans `resetsAt` ⇒ `RateLimited{None}` |
|
||||||
|
| **LS3** | domaine+infra | port `Scheduler` + `ScheduledTask` ; `TokioScheduler` (arm/cancel + push mpsc) | `arm` tire la tâche à l'échéance (deadline courte) ; `cancel` empêche le tir ; `cancel` post-tir = `false` |
|
||||||
|
| **LS4** | application | `SessionLimitService` (on_rate_limited/cancel/resume_now/drain) ; `structured.rs` réconcilie T4 | mocks `Scheduler`+`AgentSession` : `on_rate_limited` arme + publie ; `cancel` annule ; drain compose `SessionPlan::Resume{id}` + prompt ; « clos sans Final + RateLimited » ⇒ **pas** d'`Io` |
|
||||||
|
| **LS5** | infra (niv. 2) | `ratelimit/RateLimitParser` (regex, dép. infra) + armement sur le watcher PTY ; conso `rate_limit_pattern` | sur une sortie TUI échantillon : match ⇒ `resets_at_ms` calculé ; profil sans motif ⇒ aucun faux positif |
|
||||||
|
| **LS6** | app+front | **niveau 3** (après lot 2) : `Stalled` sans limite connue ⇒ `AgentRateLimitSuspected` + `confirm_agent_rate_limit` | mock : transition `Stalled` sans `SessionLimit` ⇒ 1 `AgentRateLimitSuspected` ; `confirm_manual` arme comme niv. 1 |
|
||||||
|
| **LS7** | app-tauri | wiring scheduler+service+drain ; commandes `cancel`/`resume_now`/`confirm` ; relais events camelCase | `cargo build`/tests commands ; events mappés ; drain démarré à open/create_project |
|
||||||
|
| **LS8** | frontend | badge « limité jusqu'à HH:MM » + countdown + Annuler ; dialogue filet humain ; gateway+adapter+mocks | Vitest avec gateway mock : badge sur `agentRateLimited` ; Annuler appelle `cancel_agent_resume` ; dialogue sur `agentRateLimitSuspected` |
|
||||||
|
|
||||||
|
**Ordre** : LS1 → (LS2 ∥ LS3) → LS4 → LS5 → **LS6 (gate : lot 2 stagnation livré)** → LS7 → LS8.
|
||||||
|
**LS1+LS2+LS4** donnent déjà le niveau 1 de bout en bout (Claude) : le cœur de valeur est atteint tôt.
|
||||||
|
|
||||||
|
### 21.10 Points ouverts (spikes)
|
||||||
|
|
||||||
|
1. **Format de `resetsAt`** (Claude) : époch vs ISO-8601 vs durée relative — à vérifier sur un vrai
|
||||||
|
`rate_limit_event` (spike LS2). Le domaine ne voit que des **époche-ms** quoi qu'il arrive.
|
||||||
|
2. **Heure locale → époche** (niveau 2) : une capture « 15:00 » est une **heure murale locale** ⇒ composer
|
||||||
|
avec la date du jour + fuseau via `Clock` ; gérer le passage de minuit (reset « demain »). Confiné infra.
|
||||||
|
3. **Codex** : existe-t-il un signal structuré de limite dans `codex exec --json` ? Sinon niveau 2 obligatoire
|
||||||
|
pour Codex (spike LS2).
|
||||||
|
4. **Double détection** : niveau 1 **et** niveau 2 pourraient matcher le même épisode ⇒ le service
|
||||||
|
**dédoublonne par agent** (une `SessionLimit` vivante par agent ; le second signal rafraîchit, n'empile pas).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
*Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.*
|
*Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.*
|
||||||
|
|||||||
28
CLAUDE.md
28
CLAUDE.md
@ -39,6 +39,21 @@ Je suis responsable de :
|
|||||||
- Produisent un **rapport d'erreurs** clair quand un test échoue.
|
- Produisent un **rapport d'erreurs** clair quand un test échoue.
|
||||||
- Re-testent après chaque correction.
|
- Re-testent après chaque correction.
|
||||||
|
|
||||||
|
### 2.4 Agent Git (1 pour tout le projet)
|
||||||
|
- Garant du **dépôt git local** : commits de l'application, création/checkout/switch de
|
||||||
|
branches, merges et rebases. Contexte : `.ideai/agents/git.md`.
|
||||||
|
- **C'est lui qui décide** de la topologie des branches, pas moi. Je le sollicite, il tranche.
|
||||||
|
- Modèle de branches : **`main`** (release) ← **`develop`** (intégration des features
|
||||||
|
terminées) ← une branche **`feature/*`** par nouvelle feature.
|
||||||
|
- **Quand je commande une nouvelle feature** : une fois l'architecture cadrée par Architect,
|
||||||
|
je passe la main à **Git** qui décide s'il faut créer une branche, faire un checkout/switch,
|
||||||
|
ou rester en place — **avant** que le dev commence.
|
||||||
|
- **Après chaque implémentation** : je reparle à **Git** pour qu'il décide si un **merge**
|
||||||
|
doit être fait quelque part (typiquement `feature/* → develop` une fois les tests verts),
|
||||||
|
ou non.
|
||||||
|
- Périmètre **local uniquement** : aucune action sortante (`push`, publication) sans ma
|
||||||
|
validation explicite.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Le cycle de développement (boucle obligatoire)
|
## 3. Le cycle de développement (boucle obligatoire)
|
||||||
@ -47,11 +62,14 @@ Pour **chaque** feature implémentée ou modifiée :
|
|||||||
|
|
||||||
```
|
```
|
||||||
1. Agent Architecture → valide le découpage et les contrats (ports/interfaces)
|
1. Agent Architecture → valide le découpage et les contrats (ports/interfaces)
|
||||||
2. Agent Développement → écrit le code
|
2. Agent Git → décide de la branche (créer feature/*, switch, ou rester)
|
||||||
3. Agent Test → écrit les tests unitaires + les exécute
|
3. Agent Développement → écrit le code
|
||||||
4a. Tests OK → feature validée, on passe à la suite
|
4. Agent Test → écrit les tests unitaires + les exécute
|
||||||
4b. Tests KO → rapport d'erreurs → retour à l'agent Développement
|
5a. Tests OK → feature validée
|
||||||
→ correction → retour à l'étape 3 (boucle jusqu'au vert)
|
→ Agent Git décide d'un merge éventuel (feature/* → develop)
|
||||||
|
→ on passe à la suite
|
||||||
|
5b. Tests KO → rapport d'erreurs → retour à l'agent Développement
|
||||||
|
→ correction → retour à l'étape 4 (boucle jusqu'au vert)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas.
|
**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas.
|
||||||
|
|||||||
32
Cargo.lock
generated
32
Cargo.lock
generated
@ -979,6 +979,26 @@ version = "1.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumflags2"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||||
|
dependencies = [
|
||||||
|
"enumflags2_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumflags2_derive"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@ -1923,6 +1943,7 @@ dependencies = [
|
|||||||
"domain",
|
"domain",
|
||||||
"fastembed",
|
"fastembed",
|
||||||
"git2",
|
"git2",
|
||||||
|
"landlock",
|
||||||
"notify",
|
"notify",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
@ -2131,6 +2152,17 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "landlock"
|
||||||
|
version = "0.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "635839550ae8b90d9fd2571460a6645dc0aec070225956ca7a2831ed31d2795d"
|
||||||
|
dependencies = [
|
||||||
|
"enumflags2",
|
||||||
|
"libc",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
|
|||||||
@ -393,7 +393,9 @@ impl AppState {
|
|||||||
let update_project_context = Arc::new(UpdateProjectContext::new(Arc::clone(&fs_port)));
|
let update_project_context = Arc::new(UpdateProjectContext::new(Arc::clone(&fs_port)));
|
||||||
|
|
||||||
// --- PTY adapter + terminal use cases (L3) ---
|
// --- PTY adapter + terminal use cases (L3) ---
|
||||||
let pty = Arc::new(PortablePtyAdapter::new());
|
let pty = Arc::new(
|
||||||
|
PortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer()),
|
||||||
|
);
|
||||||
let pty_port = Arc::clone(&pty) as Arc<dyn PtyPort>;
|
let pty_port = Arc::clone(&pty) as Arc<dyn PtyPort>;
|
||||||
let terminal_sessions = Arc::new(TerminalSessions::new());
|
let terminal_sessions = Arc::new(TerminalSessions::new());
|
||||||
|
|
||||||
@ -402,8 +404,10 @@ impl AppState {
|
|||||||
// `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et
|
// `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et
|
||||||
// ChangeAgentProfile (shutdown polymorphe au hot-swap).
|
// ChangeAgentProfile (shutdown polymorphe au hot-swap).
|
||||||
let structured_sessions = Arc::new(StructuredSessions::new());
|
let structured_sessions = Arc::new(StructuredSessions::new());
|
||||||
let session_factory =
|
let session_factory = Arc::new(
|
||||||
Arc::new(StructuredSessionFactory::new()) as Arc<dyn AgentSessionFactory>;
|
StructuredSessionFactory::new()
|
||||||
|
.with_sandbox_enforcer(infrastructure::default_enforcer()),
|
||||||
|
) as Arc<dyn AgentSessionFactory>;
|
||||||
|
|
||||||
let open_terminal = Arc::new(OpenTerminal::new(
|
let open_terminal = Arc::new(OpenTerminal::new(
|
||||||
Arc::clone(&pty_port),
|
Arc::clone(&pty_port),
|
||||||
|
|||||||
@ -28,6 +28,7 @@ use domain::{
|
|||||||
ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore,
|
ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore,
|
||||||
PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||||
};
|
};
|
||||||
|
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::layout::{persist_doc, resolve_doc};
|
use crate::layout::{persist_doc, resolve_doc};
|
||||||
@ -1510,6 +1511,22 @@ impl LaunchAgent {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// 5d. ── PLAN DE SANDBOX OS (lot LP4-3) ──
|
||||||
|
// Strictement APRÈS la résolution des permissions (3) et la projection
|
||||||
|
// advisory (5c), AVANT le split structuré/PTY : `compile_sandbox_plan`
|
||||||
|
// est **pur** (domaine) — le launch path ne fait qu'orchestrer
|
||||||
|
// `EffectivePermissions` (déjà lue du store en 3) → `SandboxPlan` et
|
||||||
|
// remplir `spec.sandbox`. `eff == None` (rien posé) ⇒ `None` : aucun plan,
|
||||||
|
// comportement natif conservé (invariant produit). L'enforcer concret
|
||||||
|
// (Landlock/Noop) est injecté côté PTY au composition root.
|
||||||
|
spec.sandbox = compile_sandbox_plan(
|
||||||
|
effective_permissions.as_ref(),
|
||||||
|
&SandboxContext {
|
||||||
|
project_root: input.project.root.as_str(),
|
||||||
|
run_dir: run_dir.as_str(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
|
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
|
||||||
// Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le
|
// Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le
|
||||||
// run dir (étape 5) ; la CLI structurée le lira à chaque tour
|
// run dir (étape 5) ; la CLI structurée le lira à chaque tour
|
||||||
@ -1552,6 +1569,7 @@ impl LaunchAgent {
|
|||||||
&input.project.root,
|
&input.project.root,
|
||||||
input.node_id,
|
input.node_id,
|
||||||
size,
|
size,
|
||||||
|
spec.sandbox.as_ref(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@ -1613,9 +1631,12 @@ impl LaunchAgent {
|
|||||||
root: &ProjectPath,
|
root: &ProjectPath,
|
||||||
node_id: Option<NodeId>,
|
node_id: Option<NodeId>,
|
||||||
size: PtySize,
|
size: PtySize,
|
||||||
|
sandbox: Option<&SandboxPlan>,
|
||||||
) -> Result<LaunchAgentOutput, AppError> {
|
) -> Result<LaunchAgentOutput, AppError> {
|
||||||
|
// Relaie le plan de sandbox OS (lot LP4-4) à la fabrique : `spec.sandbox`,
|
||||||
|
// déjà compilé (pur, domaine) en step 5d. `None` ⇒ exécution native inchangée.
|
||||||
let session = factory
|
let session = factory
|
||||||
.start(profile, prepared, run_dir, session_plan)
|
.start(profile, prepared, run_dir, session_plan, sandbox)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Process(e.to_string()))?;
|
.map_err(|e| AppError::Process(e.to_string()))?;
|
||||||
|
|
||||||
|
|||||||
@ -3022,6 +3022,7 @@ impl AgentSessionFactory for CountingFactory {
|
|||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
_session: &SessionPlan,
|
_session: &SessionPlan,
|
||||||
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||||
self.starts.fetch_add(1, Ordering::SeqCst);
|
self.starts.fetch_add(1, Ordering::SeqCst);
|
||||||
let id = {
|
let id = {
|
||||||
|
|||||||
@ -520,6 +520,7 @@ impl AgentSessionFactory for FakeFactory {
|
|||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
session: &SessionPlan,
|
session: &SessionPlan,
|
||||||
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||||
self.starts
|
self.starts
|
||||||
.lock()
|
.lock()
|
||||||
@ -1114,7 +1115,7 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
|||||||
let cwd = ProjectPath::new(ROOT).unwrap();
|
let cwd = ProjectPath::new(ROOT).unwrap();
|
||||||
let session = f
|
let session = f
|
||||||
.factory
|
.factory
|
||||||
.start(&profile, &ctx, &cwd, &SessionPlan::None)
|
.start(&profile, &ctx, &cwd, &SessionPlan::None, None)
|
||||||
.await
|
.await
|
||||||
.expect("seed structured session");
|
.expect("seed structured session");
|
||||||
f.structured.insert(session, agent.id, host);
|
f.structured.insert(session, agent.id, host);
|
||||||
|
|||||||
@ -209,6 +209,55 @@ pub enum DomainEvent {
|
|||||||
/// Target profile's submit delay in ms. `None` ⇒ front default (~60 ms).
|
/// Target profile's submit delay in ms. `None` ⇒ front default (~60 ms).
|
||||||
submit_delay_ms: Option<u32>,
|
submit_delay_ms: Option<u32>,
|
||||||
},
|
},
|
||||||
|
/// Un agent vient d'entrer en **limite de session/débit** (ARCHITECTURE §21).
|
||||||
|
/// Publié quand le service de limite enregistre une nouvelle `SessionLimit` (niveau
|
||||||
|
/// 1 structuré ou niveau 2 motif). Balise discrète, basse fréquence, relayée au
|
||||||
|
/// front pour afficher le badge « limité jusqu'à HH:MM ». Model-agnostique : ne
|
||||||
|
/// porte que le fait neutre « limité, reset à T (peut-être) ».
|
||||||
|
AgentRateLimited {
|
||||||
|
/// L'agent entré en limite.
|
||||||
|
agent_id: AgentId,
|
||||||
|
/// Instant de reset en **époche-millisecondes**. `None` ⇒ heure inconnue
|
||||||
|
/// (pas de reprise auto, filet humain).
|
||||||
|
resets_at_ms: Option<i64>,
|
||||||
|
},
|
||||||
|
/// Une **reprise automatique** a été armée pour un agent limité (ARCHITECTURE §21).
|
||||||
|
/// Publié après que le service a calculé le plan ([`crate::session_limit::plan_resume`])
|
||||||
|
/// et armé le `Scheduler`. Relayé au front pour afficher le compte à rebours + le
|
||||||
|
/// bouton « Annuler la reprise » (fenêtre annulable).
|
||||||
|
AgentResumeScheduled {
|
||||||
|
/// L'agent dont la reprise est programmée.
|
||||||
|
agent_id: AgentId,
|
||||||
|
/// Échéance du réveil en **époche-millisecondes**.
|
||||||
|
fire_at_ms: i64,
|
||||||
|
},
|
||||||
|
/// La **reprise automatique** d'un agent a été **annulée** (ARCHITECTURE §21) :
|
||||||
|
/// l'utilisateur a cliqué « Annuler la reprise » dans la fenêtre annulable. Relayé
|
||||||
|
/// au front pour retirer le compte à rebours.
|
||||||
|
AgentResumeCancelled {
|
||||||
|
/// L'agent dont la reprise a été annulée.
|
||||||
|
agent_id: AgentId,
|
||||||
|
},
|
||||||
|
/// Un agent a effectivement été **relancé** après une limite (ARCHITECTURE §21) :
|
||||||
|
/// le réveil a tiré (ou reprise immédiate), l'agent a redémarré via
|
||||||
|
/// [`crate::ports::SessionPlan::Resume`] avec un prompt de reprise court. Relayé au
|
||||||
|
/// front pour effacer l'état « limité ».
|
||||||
|
AgentResumed {
|
||||||
|
/// L'agent relancé.
|
||||||
|
agent_id: AgentId,
|
||||||
|
},
|
||||||
|
/// **Filet humain (niveau 3)** : une limite de session est **suspectée** sans
|
||||||
|
/// qu'aucune heure de reset fiable ne soit connue (ARCHITECTURE §21.1 niveau 3) —
|
||||||
|
/// typiquement un agent passé `Stalled` (lot 2) sans `SessionLimit` connue. IdeA ne
|
||||||
|
/// reprend **jamais** à l'aveugle : ce signal demande au front de **solliciter
|
||||||
|
/// l'utilisateur** (« limite détectée mais heure inconnue — reprendre à ? »).
|
||||||
|
AgentRateLimitSuspected {
|
||||||
|
/// L'agent dont la limite est suspectée.
|
||||||
|
agent_id: AgentId,
|
||||||
|
/// Instant de reset en **époche-millisecondes** si une estimation existe,
|
||||||
|
/// sinon `None` (l'utilisateur fournira l'heure).
|
||||||
|
resets_at_ms: Option<i64>,
|
||||||
|
},
|
||||||
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
|
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
|
||||||
PtyOutput {
|
PtyOutput {
|
||||||
/// The session.
|
/// The session.
|
||||||
@ -217,3 +266,104 @@ pub enum DomainEvent {
|
|||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn agent(n: u128) -> AgentId {
|
||||||
|
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- §21 : constructibilité + égalité PartialEq des 5 variantes --------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_rate_limited_constructs_and_compares() {
|
||||||
|
let ev = DomainEvent::AgentRateLimited {
|
||||||
|
agent_id: agent(1),
|
||||||
|
resets_at_ms: Some(1_700_000_000_000),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
ev,
|
||||||
|
DomainEvent::AgentRateLimited {
|
||||||
|
agent_id: agent(1),
|
||||||
|
resets_at_ms: Some(1_700_000_000_000),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Une heure différente ⇒ inégaux.
|
||||||
|
assert_ne!(
|
||||||
|
ev,
|
||||||
|
DomainEvent::AgentRateLimited {
|
||||||
|
agent_id: agent(1),
|
||||||
|
resets_at_ms: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_resume_scheduled_constructs_and_compares() {
|
||||||
|
let ev = DomainEvent::AgentResumeScheduled {
|
||||||
|
agent_id: agent(2),
|
||||||
|
fire_at_ms: 1_700_000_000_000,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
ev,
|
||||||
|
DomainEvent::AgentResumeScheduled {
|
||||||
|
agent_id: agent(2),
|
||||||
|
fire_at_ms: 1_700_000_000_000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
ev,
|
||||||
|
DomainEvent::AgentResumeScheduled {
|
||||||
|
agent_id: agent(2),
|
||||||
|
fire_at_ms: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_resume_cancelled_constructs_and_compares() {
|
||||||
|
let ev = DomainEvent::AgentResumeCancelled { agent_id: agent(3) };
|
||||||
|
assert_eq!(ev, DomainEvent::AgentResumeCancelled { agent_id: agent(3) });
|
||||||
|
assert_ne!(ev, DomainEvent::AgentResumeCancelled { agent_id: agent(4) });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_resumed_constructs_and_compares() {
|
||||||
|
let ev = DomainEvent::AgentResumed { agent_id: agent(5) };
|
||||||
|
assert_eq!(ev, DomainEvent::AgentResumed { agent_id: agent(5) });
|
||||||
|
assert_ne!(ev, DomainEvent::AgentResumed { agent_id: agent(6) });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_rate_limit_suspected_constructs_and_compares() {
|
||||||
|
let ev = DomainEvent::AgentRateLimitSuspected {
|
||||||
|
agent_id: agent(7),
|
||||||
|
resets_at_ms: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
ev,
|
||||||
|
DomainEvent::AgentRateLimitSuspected {
|
||||||
|
agent_id: agent(7),
|
||||||
|
resets_at_ms: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
ev,
|
||||||
|
DomainEvent::AgentRateLimitSuspected {
|
||||||
|
agent_id: agent(7),
|
||||||
|
resets_at_ms: Some(42),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distinct_session_limit_variants_are_not_equal() {
|
||||||
|
// Les variantes ne se confondent pas entre elles malgré des champs proches.
|
||||||
|
assert_ne!(
|
||||||
|
DomainEvent::AgentResumeCancelled { agent_id: agent(8) },
|
||||||
|
DomainEvent::AgentResumed { agent_id: agent(8) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -50,6 +50,7 @@ pub mod profile;
|
|||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod readiness;
|
pub mod readiness;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
|
pub mod session_limit;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
pub mod template;
|
pub mod template;
|
||||||
@ -78,7 +79,7 @@ pub use template::{AgentTemplate, TemplateVersion};
|
|||||||
|
|
||||||
pub use profile::{
|
pub use profile::{
|
||||||
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy,
|
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy,
|
||||||
McpServerWiring, SessionStrategy,
|
McpServerWiring, RateLimitPattern, SessionStrategy,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
|
pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
|
||||||
@ -92,6 +93,8 @@ pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource};
|
|||||||
|
|
||||||
pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
||||||
|
|
||||||
|
pub use session_limit::{plan_resume, ResumePlan, RateLimitSource, SessionLimit};
|
||||||
|
|
||||||
pub use conversation_log::{
|
pub use conversation_log::{
|
||||||
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||||
ProviderSessionStore, TurnId, TurnRole,
|
ProviderSessionStore, TurnId, TurnRole,
|
||||||
|
|||||||
@ -225,6 +225,26 @@ pub enum ReplyEvent {
|
|||||||
/// jusqu'au [`ReplyEvent::Final`]. Un tour comporte ≥0 `Heartbeat`, jamais
|
/// jusqu'au [`ReplyEvent::Final`]. Un tour comporte ≥0 `Heartbeat`, jamais
|
||||||
/// d'obligation d'en émettre.
|
/// d'obligation d'en émettre.
|
||||||
Heartbeat,
|
Heartbeat,
|
||||||
|
/// **Limite de session/débit atteinte** (model-agnostique, ARCHITECTURE §21) :
|
||||||
|
/// l'adapter structuré a observé que le moteur a suspendu l'agent pour cause de
|
||||||
|
/// quota (Claude `rate_limit_event` → `rate_limit_info.resetsAt`, ou équivalent).
|
||||||
|
/// Porte un fait neutre : « limité, reset à T (peut-être) ». Aucun détail propre
|
||||||
|
/// à une CLI ne franchit la frontière (forme du `rate_limit_event`, format de
|
||||||
|
/// l'heure…) — tout cela reste confiné à l'adapter (cf. §21.2-T2).
|
||||||
|
///
|
||||||
|
/// **Jamais terminal** (cf. §21.2-T4) : exactement comme [`ReplyEvent::Heartbeat`],
|
||||||
|
/// un `RateLimited` **ne clôt pas** le flux — il s'intercale et le flux continue
|
||||||
|
/// jusqu'au [`ReplyEvent::Final`] **ou** jusqu'à une clôture du flux. Conséquence
|
||||||
|
/// pour les consommateurs : un tour clos **sans `Final`** parce que limité doit
|
||||||
|
/// être traité comme une **fin gracieuse limitée** (et non comme une erreur « flux
|
||||||
|
/// clos sans Final »), dès lors qu'un `RateLimited` a été vu dans le tour.
|
||||||
|
RateLimited {
|
||||||
|
/// Instant de réinitialisation de la limite, en **époche-millisecondes**
|
||||||
|
/// (homogène avec [`Clock::now_millis`]). `None` quand le moteur n'a pas
|
||||||
|
/// fourni d'heure de reset exploitable ⇒ pas de reprise auto possible (filet
|
||||||
|
/// humain, §21.1 niveau 3). Jamais une `Instant` monotone (cf. §21.2-T1).
|
||||||
|
resets_at_ms: Option<i64>,
|
||||||
|
},
|
||||||
/// **Événement terminal déterministe** d'un tour : l'adapter l'émet quand il a
|
/// **Événement terminal déterministe** d'un tour : l'adapter l'émet quand il a
|
||||||
/// lu le message `result` documenté de la CLI. Porte le contenu final agrégé.
|
/// lu le message `result` documenté de la CLI. Porte le contenu final agrégé.
|
||||||
/// Après `Final`, le flux se termine (plus aucun événement).
|
/// Après `Final`, le flux se termine (plus aucun événement).
|
||||||
@ -531,6 +551,13 @@ pub trait AgentSessionFactory: Send + Sync {
|
|||||||
/// §14.1), avec le contexte déjà préparé ([`PreparedContext`]) et l'intention de
|
/// §14.1), avec le contexte déjà préparé ([`PreparedContext`]) et l'intention de
|
||||||
/// session ([`SessionPlan`] : neuf / assign / resume — réutilise §15).
|
/// session ([`SessionPlan`] : neuf / assign / resume — réutilise §15).
|
||||||
///
|
///
|
||||||
|
/// `sandbox` est le plan de sandbox OS **par lancement** (lot LP4-4), déjà
|
||||||
|
/// compilé (pur, domaine) au launch path et porté jusqu'ici comme il l'est pour
|
||||||
|
/// le chemin PTY via [`SpawnSpec::sandbox`]. `None` ⇒ aucun plan ⇒ exécution
|
||||||
|
/// native inchangée (invariant produit : rien posé ⇒ rien projeté). Le plan
|
||||||
|
/// franchit le port en tant que **valeur domaine** ([`crate::sandbox::SandboxPlan`]) ;
|
||||||
|
/// l'enforcer concret reste côté infra (injecté par instance dans la fabrique).
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// [`AgentSessionError::Start`] si la CLI/SDK est indisponible ou le mode
|
/// [`AgentSessionError::Start`] si la CLI/SDK est indisponible ou le mode
|
||||||
/// structuré ne peut s'initialiser.
|
/// structuré ne peut s'initialiser.
|
||||||
@ -540,6 +567,7 @@ pub trait AgentSessionFactory: Send + Sync {
|
|||||||
ctx: &PreparedContext,
|
ctx: &PreparedContext,
|
||||||
cwd: &ProjectPath,
|
cwd: &ProjectPath,
|
||||||
session: &SessionPlan,
|
session: &SessionPlan,
|
||||||
|
sandbox: Option<&crate::sandbox::SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError>;
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -174,6 +174,64 @@ impl LivenessStrategy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Motif déclaratif de détection d'une **limite de session/débit** pour un agent
|
||||||
|
/// **PTY/TUI sans adapter structuré** (ARCHITECTURE §21, niveau 2 de détection).
|
||||||
|
///
|
||||||
|
/// Donnée **pure** (pas de code par CLI — Open/Closed, §9), calquée sur la
|
||||||
|
/// philosophie de [`AgentProfile::prompt_ready_pattern`] mais **plus riche** : là où
|
||||||
|
/// le retour-de-prompt est une simple sous-chaîne littérale, la limite de session a
|
||||||
|
/// besoin d'**extraire une heure de reset** dans la sortie. Le domaine **ne stocke
|
||||||
|
/// que la donnée** (chaînes) ; le **moteur regex et le parsing d'heure vivent en
|
||||||
|
/// infrastructure** (composant `RateLimitParser`, dépendance `regex` ajoutée au seul
|
||||||
|
/// `Cargo.toml` d'`infrastructure`, cf. §21.2-T2). **Aucune** regex ni heure parsée
|
||||||
|
/// ne franchit la frontière domaine : l'infra émet un
|
||||||
|
/// [`crate::ports::ReplyEvent::RateLimited`] avec une époche-ms normalisée.
|
||||||
|
///
|
||||||
|
/// Invariant (garanti par le constructeur) : `pattern` est non vide.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RateLimitPattern {
|
||||||
|
/// Le **motif brut** (interprété comme une regex par l'infra) recherché dans la
|
||||||
|
/// sortie PTY pour reconnaître l'épisode de limite. Le domaine ne le compile
|
||||||
|
/// jamais — il le transporte tel quel jusqu'à l'adapter (§21.2-T2).
|
||||||
|
pub pattern: String,
|
||||||
|
/// Nom du **groupe de capture** (ou indication équivalente) d'où l'infra extrait
|
||||||
|
/// l'heure de reset. `None` ⇒ le motif détecte la limite **sans** heure de reset
|
||||||
|
/// ⇒ [`crate::ports::ReplyEvent::RateLimited`]`{ resets_at_ms: None }` (filet
|
||||||
|
/// humain). Donnée opaque au domaine.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reset_capture: Option<String>,
|
||||||
|
/// **Format d'heure** (ex. style `strftime`) que l'infra utilise pour parser la
|
||||||
|
/// chaîne capturée par `reset_capture` en une heure murale, qu'elle compose
|
||||||
|
/// ensuite avec la date du jour + le fuseau via `Clock` pour obtenir une
|
||||||
|
/// époche-ms (§21.10-2). `None` ⇒ l'infra applique sa stratégie de parsing par
|
||||||
|
/// défaut. Donnée opaque au domaine.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub time_format: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimitPattern {
|
||||||
|
/// Construit un motif validé (parse-don't-validate, comme
|
||||||
|
/// [`SessionStrategy::new`] / [`LivenessStrategy::new`]).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Renvoie [`DomainError::EmptyField`] (`"rateLimitPattern.pattern"`) si
|
||||||
|
/// `pattern` est vide.
|
||||||
|
pub fn new(
|
||||||
|
pattern: impl Into<String>,
|
||||||
|
reset_capture: Option<String>,
|
||||||
|
time_format: Option<String>,
|
||||||
|
) -> Result<Self, DomainError> {
|
||||||
|
let pattern = pattern.into();
|
||||||
|
crate::validation::non_empty(&pattern, "rateLimitPattern.pattern")?;
|
||||||
|
Ok(Self {
|
||||||
|
pattern,
|
||||||
|
reset_capture,
|
||||||
|
time_format,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17).
|
/// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17).
|
||||||
///
|
///
|
||||||
/// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel
|
/// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel
|
||||||
@ -553,6 +611,21 @@ pub struct AgentProfile {
|
|||||||
/// un profil sans cette clé sérialise exactement comme avant.
|
/// un profil sans cette clé sérialise exactement comme avant.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub liveness: Option<LivenessStrategy>,
|
pub liveness: Option<LivenessStrategy>,
|
||||||
|
/// Motif déclaratif de détection d'une **limite de session/débit** (ARCHITECTURE
|
||||||
|
/// §21, niveau 2). `None` (défaut, et valeur des profils existants) ⇒ **aucune**
|
||||||
|
/// détection par motif : seuls les agents structurés (niveau 1) ou le filet
|
||||||
|
/// humain (niveau 3) couvrent la limite. `Some(_)` ⇒ pour un agent **PTY/TUI sans
|
||||||
|
/// adapter structuré**, IdeA observe la sortie et émet un
|
||||||
|
/// [`crate::ports::ReplyEvent::RateLimited`] sur match.
|
||||||
|
///
|
||||||
|
/// Le domaine ne porte que la **donnée** ([`RateLimitPattern`]) ; le moteur regex
|
||||||
|
/// + le parsing d'heure vivent en infra (§21.2-T2) — domaine dépendance-zéro
|
||||||
|
/// préservé.
|
||||||
|
///
|
||||||
|
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation :
|
||||||
|
/// un profil sans cette clé sérialise exactement comme avant.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rate_limit_pattern: Option<RateLimitPattern>,
|
||||||
/// Séquence de soumission écrite **après** le texte d'une délégation pour la
|
/// Séquence de soumission écrite **après** le texte d'une délégation pour la
|
||||||
/// faire valider par la CLI (§20.3, fix Bug 1). Le portail d'écriture (front)
|
/// faire valider par la CLI (§20.3, fix Bug 1). Le portail d'écriture (front)
|
||||||
/// écrit d'abord le texte (sans `\n`, pour esquiver la détection de paste de
|
/// écrit d'abord le texte (sans `\n`, pour esquiver la détection de paste de
|
||||||
@ -726,6 +799,7 @@ impl AgentProfile {
|
|||||||
mcp: None,
|
mcp: None,
|
||||||
prompt_ready_pattern: None,
|
prompt_ready_pattern: None,
|
||||||
liveness: None,
|
liveness: None,
|
||||||
|
rate_limit_pattern: None,
|
||||||
submit_sequence: None,
|
submit_sequence: None,
|
||||||
submit_delay_ms: None,
|
submit_delay_ms: None,
|
||||||
projector: None,
|
projector: None,
|
||||||
@ -768,6 +842,15 @@ impl AgentProfile {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builder : fixe le [`RateLimitPattern`] de détection de limite (§21, niveau 2)
|
||||||
|
/// et renvoie le profil. Laisse [`AgentProfile::new`] stable (zéro régression
|
||||||
|
/// d'appel) : les profils sans détection par motif ne l'appellent simplement pas.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_rate_limit_pattern(mut self, pattern: RateLimitPattern) -> Self {
|
||||||
|
self.rate_limit_pattern = Some(pattern);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Builder : fixe la [`Self::submit_sequence`] (§20.3, fix Bug 1) et renvoie le
|
/// Builder : fixe la [`Self::submit_sequence`] (§20.3, fix Bug 1) et renvoie le
|
||||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||||
/// profils qui s'en remettent au défaut `"\r"` ne l'appellent simplement pas.
|
/// profils qui s'en remettent au défaut `"\r"` ne l'appellent simplement pas.
|
||||||
@ -1366,4 +1449,96 @@ mod mcp_tests {
|
|||||||
"transport expected; got: {toml}"
|
"transport expected; got: {toml}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- §21 : rate_limit_pattern (détection de limite par motif, niveau 2) ------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_pattern_new_rejects_empty_pattern() {
|
||||||
|
let err = RateLimitPattern::new("", None, None).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, DomainError::EmptyField { field } if field == "rateLimitPattern.pattern"),
|
||||||
|
"un motif vide doit être rejeté; got: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_pattern_new_accepts_non_empty_pattern() {
|
||||||
|
let p = RateLimitPattern::new(
|
||||||
|
"rate limit.*resets at (?P<reset>.+)",
|
||||||
|
Some("reset".to_owned()),
|
||||||
|
Some("%H:%M".to_owned()),
|
||||||
|
)
|
||||||
|
.expect("valid pattern");
|
||||||
|
assert_eq!(p.pattern, "rate limit.*resets at (?P<reset>.+)");
|
||||||
|
assert_eq!(p.reset_capture.as_deref(), Some("reset"));
|
||||||
|
assert_eq!(p.time_format.as_deref(), Some("%H:%M"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_default_has_no_rate_limit_pattern() {
|
||||||
|
// Profils existants (via `new`) : aucun motif de limite.
|
||||||
|
assert!(profile_without_mcp().rate_limit_pattern.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_without_rate_limit_pattern_omits_key_in_json() {
|
||||||
|
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
|
||||||
|
assert!(
|
||||||
|
!json.contains("rateLimitPattern"),
|
||||||
|
"a profile without a rate-limit pattern must NOT serialise the key (zero regression); got: {json}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_json_without_rate_limit_pattern_deserialises_to_none() {
|
||||||
|
// JSON produit avant l'existence du champ : aucune clé `rateLimitPattern`.
|
||||||
|
let legacy = r#"{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"name": "Dev",
|
||||||
|
"command": "claude",
|
||||||
|
"args": [],
|
||||||
|
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
|
||||||
|
"detect": null,
|
||||||
|
"cwdTemplate": "{agentRunDir}"
|
||||||
|
}"#;
|
||||||
|
let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
|
||||||
|
assert!(profile.rate_limit_pattern.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_rate_limit_pattern_sets_and_round_trips_camel_case() {
|
||||||
|
let pattern = RateLimitPattern::new(
|
||||||
|
"limit reached, resets (?P<resetAt>.+)",
|
||||||
|
Some("resetAt".to_owned()),
|
||||||
|
Some("%-I%p".to_owned()),
|
||||||
|
)
|
||||||
|
.expect("valid pattern");
|
||||||
|
let profile = profile_without_mcp().with_rate_limit_pattern(pattern.clone());
|
||||||
|
assert_eq!(profile.rate_limit_pattern, Some(pattern));
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&profile).expect("serialise");
|
||||||
|
assert!(json.contains("rateLimitPattern"), "key present: {json}");
|
||||||
|
// camelCase respecté sur les champs de RateLimitPattern.
|
||||||
|
assert!(json.contains("resetCapture"), "camelCase field resetCapture: {json}");
|
||||||
|
assert!(json.contains("timeFormat"), "camelCase field timeFormat: {json}");
|
||||||
|
|
||||||
|
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||||
|
assert_eq!(profile, back);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_pattern_omits_unset_optional_fields_in_json() {
|
||||||
|
// reset_capture / time_format à None ⇒ leurs clés sont omises.
|
||||||
|
let pattern = RateLimitPattern::new("rate limited", None, None).expect("valid pattern");
|
||||||
|
let json = serde_json::to_string(&pattern).expect("serialise");
|
||||||
|
assert!(json.contains("\"pattern\""), "pattern field present: {json}");
|
||||||
|
assert!(
|
||||||
|
!json.contains("resetCapture"),
|
||||||
|
"an unset resetCapture must be omitted; got: {json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!json.contains("timeFormat"),
|
||||||
|
"an unset timeFormat must be omitted; got: {json}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,6 +49,20 @@ pub enum ReadinessSignal {
|
|||||||
/// Le garde-fou de durée de tour a expiré. Place réservée au **lot 2** — non
|
/// Le garde-fou de durée de tour a expiré. Place réservée au **lot 2** — non
|
||||||
/// produit dans ce lot.
|
/// produit dans ce lot.
|
||||||
TimedOut,
|
TimedOut,
|
||||||
|
/// L'agent est en **limite de session/débit** (ARCHITECTURE §21) : le moteur l'a
|
||||||
|
/// suspendu pour cause de quota. Déduit de [`ReplyEvent::RateLimited`] par
|
||||||
|
/// [`ReadinessPolicy::classify`]. **Pas terminal** au sens « fin de tour » : il ne
|
||||||
|
/// marque pas le tour comme `TurnEnded` (l'agent n'a pas rendu son `Final`) — il
|
||||||
|
/// signale un 3ᵉ axe d'état orthogonal (« limité jusqu'à T »), que l'application
|
||||||
|
/// traduit en planification de reprise.
|
||||||
|
///
|
||||||
|
/// `Copy` préservé : `Option<i64>` est `Copy`, donc l'enum le reste (cf. §21.3).
|
||||||
|
RateLimited {
|
||||||
|
/// Instant de reset en **époche-millisecondes** (cf.
|
||||||
|
/// [`ReplyEvent::RateLimited::resets_at_ms`]). `None` ⇒ pas d'heure connue
|
||||||
|
/// (filet humain, pas de reprise auto).
|
||||||
|
resets_at_ms: Option<i64>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Politique **pure** de classification d'un événement de tour en
|
/// Politique **pure** de classification d'un événement de tour en
|
||||||
@ -63,6 +77,11 @@ impl ReadinessPolicy {
|
|||||||
///
|
///
|
||||||
/// - [`ReplyEvent::Final`] ⇒ `Some(`[`ReadinessSignal::TurnEnded`]`)` : seul
|
/// - [`ReplyEvent::Final`] ⇒ `Some(`[`ReadinessSignal::TurnEnded`]`)` : seul
|
||||||
/// événement terminal, il signe la fin de tour déterministe.
|
/// événement terminal, il signe la fin de tour déterministe.
|
||||||
|
/// - [`ReplyEvent::RateLimited`] ⇒
|
||||||
|
/// `Some(`[`ReadinessSignal::RateLimited`]`{..})` : **non terminal** (l'agent
|
||||||
|
/// n'a pas rendu son `Final`), mais porteur d'un signal exploitable par
|
||||||
|
/// l'application (planifier la reprise à `resets_at_ms`). L'heure de reset est
|
||||||
|
/// propagée telle quelle.
|
||||||
/// - [`ReplyEvent::TextDelta`] / [`ReplyEvent::ToolActivity`] /
|
/// - [`ReplyEvent::TextDelta`] / [`ReplyEvent::ToolActivity`] /
|
||||||
/// [`ReplyEvent::Heartbeat`] ⇒ `None` : tous **non terminaux** (le flux
|
/// [`ReplyEvent::Heartbeat`] ⇒ `None` : tous **non terminaux** (le flux
|
||||||
/// continue). Un heartbeat prouve la vivacité mais ne termine pas le tour.
|
/// continue). Un heartbeat prouve la vivacité mais ne termine pas le tour.
|
||||||
@ -70,6 +89,11 @@ impl ReadinessPolicy {
|
|||||||
pub const fn classify(event: &ReplyEvent) -> Option<ReadinessSignal> {
|
pub const fn classify(event: &ReplyEvent) -> Option<ReadinessSignal> {
|
||||||
match event {
|
match event {
|
||||||
ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded),
|
ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded),
|
||||||
|
ReplyEvent::RateLimited { resets_at_ms } => {
|
||||||
|
Some(ReadinessSignal::RateLimited {
|
||||||
|
resets_at_ms: *resets_at_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
ReplyEvent::TextDelta { .. }
|
ReplyEvent::TextDelta { .. }
|
||||||
| ReplyEvent::ToolActivity { .. }
|
| ReplyEvent::ToolActivity { .. }
|
||||||
| ReplyEvent::Heartbeat => None,
|
| ReplyEvent::Heartbeat => None,
|
||||||
@ -108,4 +132,51 @@ mod tests {
|
|||||||
"un heartbeat prouve la vivacité mais ne termine JAMAIS le tour"
|
"un heartbeat prouve la vivacité mais ne termine JAMAIS le tour"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- §21 : RateLimited ⇒ ReadinessSignal::RateLimited (heure propagée) -------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limited_with_known_reset_classifies_and_propagates_time() {
|
||||||
|
let ev = ReplyEvent::RateLimited {
|
||||||
|
resets_at_ms: Some(1_700_000_000_000),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
ReadinessPolicy::classify(&ev),
|
||||||
|
Some(ReadinessSignal::RateLimited {
|
||||||
|
resets_at_ms: Some(1_700_000_000_000),
|
||||||
|
}),
|
||||||
|
"l'heure de reset doit être propagée telle quelle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limited_without_reset_classifies_with_none() {
|
||||||
|
let ev = ReplyEvent::RateLimited { resets_at_ms: None };
|
||||||
|
assert_eq!(
|
||||||
|
ReadinessPolicy::classify(&ev),
|
||||||
|
Some(ReadinessSignal::RateLimited { resets_at_ms: None }),
|
||||||
|
"heure inconnue ⇒ None propagé (filet humain en aval)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limited_is_not_classified_as_turn_ended() {
|
||||||
|
// Non terminal : un RateLimited ne marque JAMAIS le tour comme fini.
|
||||||
|
let signal = ReadinessPolicy::classify(&ReplyEvent::RateLimited {
|
||||||
|
resets_at_ms: Some(42),
|
||||||
|
});
|
||||||
|
assert_ne!(signal, Some(ReadinessSignal::TurnEnded));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readiness_signal_is_copy() {
|
||||||
|
// Test de compilation : `ReadinessSignal` reste `Copy` (cf. §21.3). Si le
|
||||||
|
// type cessait d'être `Copy`, l'usage après le `copy` ci-dessous (move
|
||||||
|
// implicite) ne compilerait plus.
|
||||||
|
let sig = ReadinessSignal::RateLimited {
|
||||||
|
resets_at_ms: Some(7),
|
||||||
|
};
|
||||||
|
let copy = sig; // copie implicite, pas un move
|
||||||
|
assert_eq!(sig, copy);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
227
crates/domain/src/session_limit.rs
Normal file
227
crates/domain/src/session_limit.rs
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
//! Limite de session/débit d'un agent — value object pur + calcul de reprise
|
||||||
|
//! (ARCHITECTURE §21).
|
||||||
|
//!
|
||||||
|
//! Objet **pur** (aucune I/O, aucun temps réel, aucune dépendance externe) : il
|
||||||
|
//! capture le **fait neutre** « cet agent est limité, reset à T (peut-être) » et
|
||||||
|
//! calcule, à partir de l'heure courante, le **plan de reprise** correspondant.
|
||||||
|
//! Tout savoir spécifique modèle (forme du `rate_limit_event` Claude, regex d'une
|
||||||
|
//! TUI, parsing d'une heure locale) reste **confiné aux adapters/profils** (§21.2).
|
||||||
|
//!
|
||||||
|
//! La limite de session est un **3ᵉ axe d'état orthogonal** aux deux axes déjà
|
||||||
|
//! posés dans [`crate::input`] ([`crate::input::AgentBusyState`] et
|
||||||
|
//! [`crate::input::AgentLiveness`]) : un agent peut être `Idle`/`Busy`,
|
||||||
|
//! `Alive`/`Stalled`, **et** limité jusqu'à une certaine heure.
|
||||||
|
//!
|
||||||
|
//! # Pourquoi des époche-millisecondes ?
|
||||||
|
//!
|
||||||
|
//! Toutes les heures manipulées ici sont des **i64 époche-millisecondes**,
|
||||||
|
//! homogènes avec [`crate::ports::Clock::now_millis`] et
|
||||||
|
//! [`crate::input::AgentBusyState`] : sérialisables, comparables, sans dépendance
|
||||||
|
//! à `std::time::Instant` (monotone, non sérialisable — cf. §21.2-T1).
|
||||||
|
|
||||||
|
/// D'où provient la détection de la limite — utile pour la traçabilité et l'UI
|
||||||
|
/// (afficher « limite structurée » vs « confirmée par l'utilisateur »).
|
||||||
|
///
|
||||||
|
/// Calqué sur la hiérarchie de détection à trois niveaux de §21.1.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RateLimitSource {
|
||||||
|
/// **Niveau 1** : extraite du flux structuré de l'adapter (le plus solide ;
|
||||||
|
/// Claude `rate_limit_info.resetsAt`, équivalent Codex…).
|
||||||
|
Structured,
|
||||||
|
/// **Niveau 2** : détectée par un motif déclaratif de profil
|
||||||
|
/// ([`crate::profile::RateLimitPattern`]) sur la sortie PTY d'un agent TUI.
|
||||||
|
Pattern,
|
||||||
|
/// **Niveau 3** : confirmée par l'utilisateur (filet humain) quand rien n'a
|
||||||
|
/// matché automatiquement mais que l'agent semble bloqué.
|
||||||
|
Human,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Value object **pur** : une limite de session/débit détectée pour un agent
|
||||||
|
/// (ARCHITECTURE §21.3).
|
||||||
|
///
|
||||||
|
/// Immuable, sans I/O, trivialement testable. Vit **en mémoire uniquement**
|
||||||
|
/// (§21.1-3) : aucune persistance, aucun store. Un agent n'a qu'**une** limite
|
||||||
|
/// vivante à la fois ; un second signal la **rafraîchit** plutôt que de l'empiler
|
||||||
|
/// (dédoublonnage assuré côté application, §21.10-4).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct SessionLimit {
|
||||||
|
/// Instant de reset en **époche-millisecondes**. `None` quand aucune heure de
|
||||||
|
/// reset exploitable n'a été obtenue ⇒ pas de reprise auto possible (filet
|
||||||
|
/// humain, §21.1 niveau 3).
|
||||||
|
pub resets_at_ms: Option<i64>,
|
||||||
|
/// Instant de **détection** de la limite, en époche-millisecondes (utile à l'UI
|
||||||
|
/// et au diagnostic ; non requis pour le calcul de reprise).
|
||||||
|
pub detected_at_ms: i64,
|
||||||
|
/// Niveau de détection ayant produit cette limite.
|
||||||
|
pub source: RateLimitSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionLimit {
|
||||||
|
/// Construit une limite de session. Pur, sans validation : tous les états
|
||||||
|
/// (`resets_at_ms` présent ou absent, n'importe quelle source) sont légitimes.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(
|
||||||
|
resets_at_ms: Option<i64>,
|
||||||
|
detected_at_ms: i64,
|
||||||
|
source: RateLimitSource,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
resets_at_ms,
|
||||||
|
detected_at_ms,
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vrai si une heure de reset exploitable est connue (⇒ reprise auto possible).
|
||||||
|
/// Faux ⇒ filet humain (l'application demande l'heure à l'utilisateur).
|
||||||
|
#[must_use]
|
||||||
|
pub const fn has_known_reset(&self) -> bool {
|
||||||
|
self.resets_at_ms.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plan de reprise calculé par [`plan_resume`] (ARCHITECTURE §21.3).
|
||||||
|
///
|
||||||
|
/// Donnée **pure** : ne porte que ce dont l'application a besoin pour armer la
|
||||||
|
/// reprise, jamais de closure ni de port (cf. l'esprit du dispatch orchestrateur
|
||||||
|
/// §14.3 — une **intention** model-agnostique, pas un effet).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ResumePlan {
|
||||||
|
/// Reprise **automatique programmée** : armer un réveil à `fire_at_ms` qui
|
||||||
|
/// relancera l'agent via [`crate::ports::SessionPlan::Resume`].
|
||||||
|
Scheduled {
|
||||||
|
/// Échéance du réveil en **époche-millisecondes**. Vaut l'heure de reset,
|
||||||
|
/// **clampée à `now_ms`** si le reset est déjà passé (on ne programme jamais
|
||||||
|
/// une échéance dans le passé : reprise immédiate).
|
||||||
|
fire_at_ms: i64,
|
||||||
|
/// Identifiant de conversation du moteur à reprendre (pivot model-agnostique,
|
||||||
|
/// porté tel quel jusqu'à [`crate::ports::SessionPlan::Resume`]). `None` ⇒
|
||||||
|
/// reprise en mode dégradé (sans id), comme le reste du chemin de reprise.
|
||||||
|
conversation_id: Option<String>,
|
||||||
|
},
|
||||||
|
/// Aucune reprise automatique possible : l'heure de reset est inconnue
|
||||||
|
/// (`SessionLimit::resets_at_ms == None`) ⇒ **filet humain** (§21.1 niveau 3).
|
||||||
|
/// L'application demandera l'heure à l'utilisateur plutôt que d'agir à l'aveugle
|
||||||
|
/// (jamais d'inaction silencieuse, jamais d'auto sans heure).
|
||||||
|
HumanFallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcule le **plan de reprise** d'un agent limité, à partir de l'heure courante
|
||||||
|
/// (ARCHITECTURE §21.3). Fonction **pure** : aucune I/O, aucun temps réel — `now_ms`
|
||||||
|
/// est injecté (cf. les fonctions pures de `LayoutTree`, §7.2), ce qui la rend
|
||||||
|
/// trivialement testable.
|
||||||
|
///
|
||||||
|
/// - `limit.resets_at_ms == Some(t)` ⇒ [`ResumePlan::Scheduled`] avec
|
||||||
|
/// `fire_at_ms = max(t, now_ms)` (on ne programme jamais dans le passé : un reset
|
||||||
|
/// déjà écoulé ⇒ reprise immédiate) et l'`conversation_id` fourni propagé tel quel.
|
||||||
|
/// - `limit.resets_at_ms == None` ⇒ [`ResumePlan::HumanFallback`] (filet humain).
|
||||||
|
#[must_use]
|
||||||
|
pub fn plan_resume(
|
||||||
|
now_ms: i64,
|
||||||
|
limit: &SessionLimit,
|
||||||
|
conversation_id: Option<String>,
|
||||||
|
) -> ResumePlan {
|
||||||
|
match limit.resets_at_ms {
|
||||||
|
Some(resets_at_ms) => ResumePlan::Scheduled {
|
||||||
|
fire_at_ms: resets_at_ms.max(now_ms),
|
||||||
|
conversation_id,
|
||||||
|
},
|
||||||
|
None => ResumePlan::HumanFallback,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const NOW: i64 = 1_700_000_000_000;
|
||||||
|
|
||||||
|
fn limit(resets_at_ms: Option<i64>) -> SessionLimit {
|
||||||
|
SessionLimit::new(resets_at_ms, NOW, RateLimitSource::Structured)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- plan_resume : reset futur ⇒ Scheduled à l'heure de reset ----------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn future_reset_schedules_at_reset_time_with_conversation_id() {
|
||||||
|
let reset = NOW + 60_000;
|
||||||
|
let plan = plan_resume(NOW, &limit(Some(reset)), Some("conv-42".to_owned()));
|
||||||
|
assert_eq!(
|
||||||
|
plan,
|
||||||
|
ResumePlan::Scheduled {
|
||||||
|
fire_at_ms: reset,
|
||||||
|
conversation_id: Some("conv-42".to_owned()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- plan_resume : clamp anti-passé ------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn past_reset_is_clamped_to_now_never_in_the_past() {
|
||||||
|
let past = NOW - 60_000;
|
||||||
|
let plan = plan_resume(NOW, &limit(Some(past)), None);
|
||||||
|
match plan {
|
||||||
|
ResumePlan::Scheduled { fire_at_ms, .. } => {
|
||||||
|
assert_eq!(fire_at_ms, NOW, "un reset déjà passé ⇒ reprise immédiate (now)");
|
||||||
|
}
|
||||||
|
other => panic!("attendu Scheduled, obtenu {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_exactly_now_fires_at_now() {
|
||||||
|
let plan = plan_resume(NOW, &limit(Some(NOW)), None);
|
||||||
|
assert_eq!(
|
||||||
|
plan,
|
||||||
|
ResumePlan::Scheduled {
|
||||||
|
fire_at_ms: NOW,
|
||||||
|
conversation_id: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- plan_resume : pas d'heure ⇒ filet humain --------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_reset_falls_back_to_human() {
|
||||||
|
let plan = plan_resume(NOW, &limit(None), Some("conv-42".to_owned()));
|
||||||
|
assert_eq!(plan, ResumePlan::HumanFallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- conversation_id propagé tel quel (Some et None) -------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conversation_id_some_is_propagated_into_scheduled() {
|
||||||
|
let reset = NOW + 1;
|
||||||
|
let plan = plan_resume(NOW, &limit(Some(reset)), Some("c".to_owned()));
|
||||||
|
assert_eq!(
|
||||||
|
plan,
|
||||||
|
ResumePlan::Scheduled {
|
||||||
|
fire_at_ms: reset,
|
||||||
|
conversation_id: Some("c".to_owned()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conversation_id_none_is_propagated_into_scheduled() {
|
||||||
|
let reset = NOW + 1;
|
||||||
|
let plan = plan_resume(NOW, &limit(Some(reset)), None);
|
||||||
|
assert_eq!(
|
||||||
|
plan,
|
||||||
|
ResumePlan::Scheduled {
|
||||||
|
fire_at_ms: reset,
|
||||||
|
conversation_id: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- SessionLimit::has_known_reset -------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn has_known_reset_is_true_with_some_and_false_with_none() {
|
||||||
|
assert!(limit(Some(NOW)).has_known_reset());
|
||||||
|
assert!(!limit(None).has_known_reset());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -340,6 +340,7 @@ impl AgentSessionFactory for FakeFactory {
|
|||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
_session: &SessionPlan,
|
_session: &SessionPlan,
|
||||||
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||||
Ok(Arc::new(FakeSession {
|
Ok(Arc::new(FakeSession {
|
||||||
id: SessionId::from_uuid(Uuid::from_u128(7)),
|
id: SessionId::from_uuid(Uuid::from_u128(7)),
|
||||||
@ -394,7 +395,7 @@ async fn fake_factory_supports_only_structured_profiles_and_starts() {
|
|||||||
};
|
};
|
||||||
let cwd = ProjectPath::new("/srv/run").unwrap();
|
let cwd = ProjectPath::new("/srv/run").unwrap();
|
||||||
let session = factory
|
let session = factory
|
||||||
.start(&structured, &ctx, &cwd, &SessionPlan::None)
|
.start(&structured, &ctx, &cwd, &SessionPlan::None, None)
|
||||||
.await
|
.await
|
||||||
.expect("factory starts a session");
|
.expect("factory starts a session");
|
||||||
assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7)));
|
assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7)));
|
||||||
|
|||||||
@ -22,6 +22,10 @@ serde = { workspace = true }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
portable-pty = "0.9"
|
portable-pty = "0.9"
|
||||||
git2 = { workspace = true }
|
git2 = { workspace = true }
|
||||||
|
|
||||||
|
# OS sandbox (lot LP4-1) — Linux Landlock LSM, best-effort/ABI-compat. Linux-only
|
||||||
|
# target dep so the Windows/macOS builds (and the Noop fallback) compile nothing
|
||||||
|
# extra. `landlock` is a thin, dependency-light wrapper over the kernel ABI.
|
||||||
# Filesystem change notifications used to *wake* the orchestrator poll loop early
|
# Filesystem change notifications used to *wake* the orchestrator poll loop early
|
||||||
# (the poll loop remains the robust cross-platform correctness guarantee).
|
# (the poll loop remains the robust cross-platform correctness guarantee).
|
||||||
notify = "6"
|
notify = "6"
|
||||||
@ -36,6 +40,9 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
|||||||
# `vector-onnx` feature; the zero-dependency default compiles nothing extra.
|
# `vector-onnx` feature; the zero-dependency default compiles nothing extra.
|
||||||
fastembed = { version = "5", default-features = false, features = ["hf-hub-rustls-tls", "ort-download-binaries-rustls-tls"], optional = true }
|
fastembed = { version = "5", default-features = false, features = ["hf-hub-rustls-tls", "ort-download-binaries-rustls-tls"], optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
landlock = "0.4.5"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…).
|
# Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…).
|
||||||
# OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency.
|
# OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency.
|
||||||
|
|||||||
@ -29,6 +29,7 @@ pub mod process;
|
|||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
|
pub mod sandbox;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
@ -55,6 +56,9 @@ pub use process::LocalProcessSpawner;
|
|||||||
pub use pty::PortablePtyAdapter;
|
pub use pty::PortablePtyAdapter;
|
||||||
pub use remote::{remote_host, LocalHost};
|
pub use remote::{remote_host, LocalHost};
|
||||||
pub use runtime::CliAgentRuntime;
|
pub use runtime::CliAgentRuntime;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub use sandbox::LandlockSandbox;
|
||||||
|
pub use sandbox::{default_enforcer, NoopSandbox};
|
||||||
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
||||||
#[cfg(feature = "vector-onnx")]
|
#[cfg(feature = "vector-onnx")]
|
||||||
pub use store::OnnxEmbedder;
|
pub use store::OnnxEmbedder;
|
||||||
|
|||||||
@ -42,9 +42,10 @@ use std::sync::{Arc, Mutex};
|
|||||||
use std::thread::JoinHandle;
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem};
|
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem, SlavePty};
|
||||||
|
|
||||||
use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec};
|
use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec};
|
||||||
|
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||||
use domain::terminal::PtySize;
|
use domain::terminal::PtySize;
|
||||||
use domain::SessionId;
|
use domain::SessionId;
|
||||||
|
|
||||||
@ -143,16 +144,32 @@ struct LivePty {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct PortablePtyAdapter {
|
pub struct PortablePtyAdapter {
|
||||||
sessions: Mutex<HashMap<SessionId, LivePty>>,
|
sessions: Mutex<HashMap<SessionId, LivePty>>,
|
||||||
|
/// Optional OS-sandbox enforcer (lot LP4-1). `None` ⇒ no sandboxing at all
|
||||||
|
/// (the default, zero behaviour change). When set **and** a [`SpawnSpec`]
|
||||||
|
/// carries a [`SandboxPlan`], the plan is enforced on the child at spawn
|
||||||
|
/// (see [`spawn_command_sandboxed`]).
|
||||||
|
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PortablePtyAdapter {
|
impl PortablePtyAdapter {
|
||||||
/// Creates an empty adapter.
|
/// Creates an empty adapter (no OS sandbox).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
sessions: Mutex::new(HashMap::new()),
|
sessions: Mutex::new(HashMap::new()),
|
||||||
|
sandbox_enforcer: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Additive builder: wires an OS-sandbox [`SandboxEnforcer`] (lot LP4-1). With
|
||||||
|
/// it set, any [`SpawnSpec`] whose `sandbox` is `Some` has that plan enforced on
|
||||||
|
/// the spawned child. Without it (the default), no spawn is ever sandboxed —
|
||||||
|
/// `new()`'s behaviour is unchanged.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_sandbox_enforcer(mut self, enforcer: Arc<dyn SandboxEnforcer>) -> Self {
|
||||||
|
self.sandbox_enforcer = Some(enforcer);
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps the domain [`PtySize`] to the `portable-pty` one.
|
/// Maps the domain [`PtySize`] to the `portable-pty` one.
|
||||||
@ -165,6 +182,54 @@ fn to_pty_size(size: PtySize) -> portable_pty::PtySize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns the child **under an OS sandbox** (lot LP4-1).
|
||||||
|
///
|
||||||
|
/// ## Why a dedicated thread instead of a `pre_exec` hook
|
||||||
|
///
|
||||||
|
/// `portable-pty` 0.9 exposes **no** user-injectable `pre_exec` closure: its
|
||||||
|
/// `CommandBuilder` has no such method, and `SlavePty::spawn_command` installs its
|
||||||
|
/// *own* internal `pre_exec` (for `setsid` / controlling-tty) before exec, with no
|
||||||
|
/// extension point. So we cannot run `enforcer.enforce(plan)` in the child's
|
||||||
|
/// `pre_exec` directly.
|
||||||
|
///
|
||||||
|
/// Instead we lean on a guaranteed kernel property: **a Landlock domain is
|
||||||
|
/// inherited across `fork` and preserved across `execve`**. We therefore restrict a
|
||||||
|
/// **dedicated throwaway thread** with `enforce`, then call `spawn_command` *from
|
||||||
|
/// that thread*. `std::process::Command` (which `spawn_command` drives) forks from
|
||||||
|
/// the calling thread — and because `portable-pty` always sets a `pre_exec`, std is
|
||||||
|
/// forced down the `fork`+`exec` path (never `posix_spawn`) — so the child inherits
|
||||||
|
/// the restricted thread's domain. The throwaway thread then dies, taking its
|
||||||
|
/// (irreversible) restriction with it, leaving IdeA's other threads untouched.
|
||||||
|
///
|
||||||
|
/// On `enforce` returning `Err` (fail-closed, e.g. a `Deny` posture on a kernel
|
||||||
|
/// without Landlock) the spawn is failed and **no child runs**. `Ok(Unsupported /
|
||||||
|
/// Degraded)` proceeds (best-effort).
|
||||||
|
///
|
||||||
|
/// Everything is **moved** into the thread (no borrow), so no `Sync` bound is
|
||||||
|
/// required of the slave; the resulting `Child` is `Send` and returned to the
|
||||||
|
/// caller.
|
||||||
|
fn spawn_command_sandboxed(
|
||||||
|
slave: Box<dyn SlavePty + Send>,
|
||||||
|
cmd: CommandBuilder,
|
||||||
|
enforcer: Arc<dyn SandboxEnforcer>,
|
||||||
|
plan: SandboxPlan,
|
||||||
|
) -> Result<Box<dyn Child + Send + Sync>, PtyError> {
|
||||||
|
let join = std::thread::spawn(move || -> Result<Box<dyn Child + Send + Sync>, PtyError> {
|
||||||
|
// Restrict THIS thread; the forked child inherits the domain.
|
||||||
|
if let Err(e) = enforcer.enforce(&plan) {
|
||||||
|
return Err(PtyError::Spawn(format!("sandbox enforcement failed: {e}")));
|
||||||
|
}
|
||||||
|
let child = slave
|
||||||
|
.spawn_command(cmd)
|
||||||
|
.map_err(|e| PtyError::Spawn(e.to_string()))?;
|
||||||
|
// The slave is held by the child; drop our copy so EOF propagates on exit.
|
||||||
|
drop(slave);
|
||||||
|
Ok(child)
|
||||||
|
});
|
||||||
|
join.join()
|
||||||
|
.map_err(|_| PtyError::Spawn("sandbox spawn thread panicked".to_owned()))?
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds the `portable-pty` command from a domain [`SpawnSpec`].
|
/// Builds the `portable-pty` command from a domain [`SpawnSpec`].
|
||||||
fn to_command(spec: &SpawnSpec) -> CommandBuilder {
|
fn to_command(spec: &SpawnSpec) -> CommandBuilder {
|
||||||
let mut cmd = CommandBuilder::new(&spec.command);
|
let mut cmd = CommandBuilder::new(&spec.command);
|
||||||
@ -185,12 +250,22 @@ impl PtyPort for PortablePtyAdapter {
|
|||||||
.map_err(|e| PtyError::Spawn(e.to_string()))?;
|
.map_err(|e| PtyError::Spawn(e.to_string()))?;
|
||||||
|
|
||||||
let cmd = to_command(&spec);
|
let cmd = to_command(&spec);
|
||||||
let child = pair
|
// Move the slave out of the pair (the master stays usable) so it can be
|
||||||
.slave
|
// either spawned directly or handed to the sandboxed-spawn thread.
|
||||||
.spawn_command(cmd)
|
let slave = pair.slave;
|
||||||
.map_err(|e| PtyError::Spawn(e.to_string()))?;
|
let child = match (spec.sandbox.clone(), self.sandbox_enforcer.clone()) {
|
||||||
// The slave is held by the child; drop our copy so EOF propagates on exit.
|
// Sandboxed spawn: a plan AND an enforcer are present.
|
||||||
drop(pair.slave);
|
(Some(plan), Some(enforcer)) => spawn_command_sandboxed(slave, cmd, enforcer, plan)?,
|
||||||
|
// No plan or no enforcer ⇒ plain spawn (the default path, unchanged).
|
||||||
|
_ => {
|
||||||
|
let child = slave
|
||||||
|
.spawn_command(cmd)
|
||||||
|
.map_err(|e| PtyError::Spawn(e.to_string()))?;
|
||||||
|
// The slave is held by the child; drop our copy so EOF propagates.
|
||||||
|
drop(slave);
|
||||||
|
child
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let writer = pair
|
let writer = pair
|
||||||
.master
|
.master
|
||||||
@ -371,3 +446,178 @@ mod tests {
|
|||||||
assert_eq!(hub.snapshot(), b"abc".to_vec());
|
assert_eq!(hub.snapshot(), b"abc".to_vec());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **End-to-end PTY × Landlock enforcement (lot LP4-3).**
|
||||||
|
///
|
||||||
|
/// These tests prove the OS sandbox is *really live on the raw PTY path*: a real
|
||||||
|
/// [`PortablePtyAdapter`] wired with the Landlock enforcer, a [`SpawnSpec`] whose
|
||||||
|
/// `sandbox = Some(plan)`, and a harmless `sh -c` that tries to write **inside** the
|
||||||
|
/// granted root (must succeed) and **outside** it (must be blocked by the kernel).
|
||||||
|
/// No AI CLI is launched — zero tokens. The whole chain enforcer → spec.sandbox →
|
||||||
|
/// `spawn` → `spawn_command_sandboxed` → restricted thread → fork/exec is exercised.
|
||||||
|
#[cfg(all(test, target_os = "linux"))]
|
||||||
|
mod sandbox_e2e_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::sandbox::LandlockSandbox;
|
||||||
|
use domain::sandbox::{PathAccess, PathGrant, SandboxPlan, SandboxStatus};
|
||||||
|
use domain::terminal::PtySize;
|
||||||
|
use domain::Posture;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// A unique temp dir for one test (no external tempfile dep), mirroring the
|
||||||
|
/// helper used by the Landlock adapter tests.
|
||||||
|
fn fresh_dir(tag: &str) -> PathBuf {
|
||||||
|
let nanos = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let p = std::env::temp_dir().join(format!("idea-pty-sandbox-{tag}-{nanos}"));
|
||||||
|
std::fs::create_dir_all(&p).unwrap();
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probes whether the running kernel actually enforces Landlock, exactly the way
|
||||||
|
/// the launch path would observe it: enforce a tiny RW plan on a throwaway thread
|
||||||
|
/// (the restriction is irreversible, so it must not run on the test thread) and
|
||||||
|
/// report `false` when the adapter returns [`SandboxStatus::Unsupported`]. Lets us
|
||||||
|
/// skip cleanly on CI / old kernels without a Landlock LSM, like the adapter tests.
|
||||||
|
fn landlock_is_enforced() -> bool {
|
||||||
|
let probe = fresh_dir("probe");
|
||||||
|
let plan = SandboxPlan {
|
||||||
|
allowed: vec![PathGrant {
|
||||||
|
abs_root: probe.to_string_lossy().into_owned(),
|
||||||
|
access: PathAccess::RW,
|
||||||
|
}],
|
||||||
|
default_posture: Posture::Ask,
|
||||||
|
};
|
||||||
|
let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan))
|
||||||
|
.join()
|
||||||
|
.unwrap()
|
||||||
|
.expect("enforce must not error under an Ask posture");
|
||||||
|
let _ = std::fs::remove_dir_all(&probe);
|
||||||
|
!matches!(status, SandboxStatus::Unsupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits (bounded) for `path` to appear, returning whether it showed up in time.
|
||||||
|
fn wait_for(path: &Path, timeout: Duration) -> bool {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if path.exists() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
path.exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// THE high-fidelity end-to-end proof. With the enforcer wired and a plan that
|
||||||
|
/// grants RW on `allowed` only, a child `sh` writing to **both** an out-of-grant
|
||||||
|
/// path and the granted path must have its out-of-grant write blocked while the
|
||||||
|
/// in-grant write lands on disk. The script writes the denied target *first*, so
|
||||||
|
/// once the allowed marker exists we know the denied write was already attempted
|
||||||
|
/// (and fenced) — making the assertion deterministic without racing the child.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pty_spawn_enforces_sandbox_plan_end_to_end() {
|
||||||
|
if !landlock_is_enforced() {
|
||||||
|
eprintln!("skipping pty_spawn_enforces_sandbox_plan_end_to_end: \
|
||||||
|
Landlock not available/enforced on this kernel");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let allowed = fresh_dir("allowed");
|
||||||
|
let denied = fresh_dir("denied");
|
||||||
|
let allowed_marker = allowed.join("in.txt");
|
||||||
|
let denied_marker = denied.join("out.txt");
|
||||||
|
|
||||||
|
// RW on `allowed` only ⇒ the write class is handled and fenced to that root;
|
||||||
|
// reads stay globally unrestricted so `sh`/libc still load normally.
|
||||||
|
let plan = SandboxPlan {
|
||||||
|
allowed: vec![PathGrant {
|
||||||
|
abs_root: allowed.to_string_lossy().into_owned(),
|
||||||
|
access: PathAccess::RW,
|
||||||
|
}],
|
||||||
|
default_posture: Posture::Ask,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Denied write FIRST, then the allowed write: the allowed marker appearing is
|
||||||
|
// a happens-after signal that the denied attempt already ran.
|
||||||
|
let script = format!(
|
||||||
|
"echo outside > '{}'; echo inside > '{}'",
|
||||||
|
denied_marker.display(),
|
||||||
|
allowed_marker.display()
|
||||||
|
);
|
||||||
|
let spec = SpawnSpec {
|
||||||
|
command: "sh".to_owned(),
|
||||||
|
args: vec!["-c".to_owned(), script],
|
||||||
|
cwd: domain::project::ProjectPath::new(allowed.to_string_lossy().into_owned())
|
||||||
|
.expect("temp dir is absolute"),
|
||||||
|
env: vec![],
|
||||||
|
context_plan: None,
|
||||||
|
sandbox: Some(plan),
|
||||||
|
};
|
||||||
|
|
||||||
|
let adapter =
|
||||||
|
PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer());
|
||||||
|
let handle = adapter
|
||||||
|
.spawn(spec, PtySize { rows: 24, cols: 80 })
|
||||||
|
.await
|
||||||
|
.expect("sandboxed spawn must succeed (Ask posture never fails closed)");
|
||||||
|
|
||||||
|
// Wait for the in-grant write to land (the child's last action).
|
||||||
|
assert!(
|
||||||
|
wait_for(&allowed_marker, Duration::from_secs(5)),
|
||||||
|
"in-grant write must succeed: {allowed_marker:?} never appeared — \
|
||||||
|
the sandbox may have wrongly fenced the granted root"
|
||||||
|
);
|
||||||
|
|
||||||
|
// THE GUARANTEE: the out-of-grant write was attempted before the marker and
|
||||||
|
// must have been blocked by Landlock, so the file must not exist.
|
||||||
|
assert!(
|
||||||
|
!denied_marker.exists(),
|
||||||
|
"SANDBOX BREACH: out-of-grant write succeeded ({denied_marker:?} exists) — \
|
||||||
|
the Landlock plan was NOT enforced on the PTY child"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = adapter.kill(&handle).await;
|
||||||
|
let _ = std::fs::remove_dir_all(&allowed);
|
||||||
|
let _ = std::fs::remove_dir_all(&denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Companion sanity check: with the **same** adapter+enforcer but a [`SpawnSpec`]
|
||||||
|
/// carrying `sandbox = None`, the out-of-grant write must succeed — proving the
|
||||||
|
/// previous test's block comes from the *enforced plan*, not from some ambient PTY
|
||||||
|
/// restriction. (Guards against a false-positive where writes fail for unrelated
|
||||||
|
/// reasons.) `denied` here is just an ordinary writable temp dir.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pty_spawn_without_plan_does_not_sandbox() {
|
||||||
|
let denied = fresh_dir("noplan");
|
||||||
|
let marker = denied.join("out.txt");
|
||||||
|
let script = format!("echo outside > '{}'", marker.display());
|
||||||
|
|
||||||
|
let spec = SpawnSpec {
|
||||||
|
command: "sh".to_owned(),
|
||||||
|
args: vec!["-c".to_owned(), script],
|
||||||
|
cwd: domain::project::ProjectPath::new(denied.to_string_lossy().into_owned())
|
||||||
|
.expect("temp dir is absolute"),
|
||||||
|
env: vec![],
|
||||||
|
context_plan: None,
|
||||||
|
sandbox: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let adapter =
|
||||||
|
PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer());
|
||||||
|
let handle = adapter
|
||||||
|
.spawn(spec, PtySize { rows: 24, cols: 80 })
|
||||||
|
.await
|
||||||
|
.expect("plain spawn must succeed");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
wait_for(&marker, Duration::from_secs(5)),
|
||||||
|
"without a sandbox plan the write must succeed: {marker:?} never appeared"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = adapter.kill(&handle).await;
|
||||||
|
let _ = std::fs::remove_dir_all(&denied);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -38,7 +38,9 @@ impl LocalHost {
|
|||||||
Self {
|
Self {
|
||||||
fs: Arc::new(LocalFileSystem::new()),
|
fs: Arc::new(LocalFileSystem::new()),
|
||||||
spawner: Arc::new(LocalProcessSpawner::new()),
|
spawner: Arc::new(LocalProcessSpawner::new()),
|
||||||
pty: Arc::new(PortablePtyAdapter::new()),
|
pty: Arc::new(
|
||||||
|
PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer()),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
479
crates/infrastructure/src/sandbox/landlock.rs
Normal file
479
crates/infrastructure/src/sandbox/landlock.rs
Normal file
@ -0,0 +1,479 @@
|
|||||||
|
//! [`LandlockSandbox`] — the Linux [`SandboxEnforcer`] backed by the kernel
|
||||||
|
//! Landlock LSM (lot LP4-1), via the `landlock` crate, **best-effort / ABI-compat**.
|
||||||
|
//!
|
||||||
|
//! # What it does
|
||||||
|
//!
|
||||||
|
//! [`SandboxEnforcer::enforce`] translates a domain [`SandboxPlan`] into a Landlock
|
||||||
|
//! ruleset and restricts the **current thread** (the contract says `enforce` runs
|
||||||
|
//! post-fork / pre-exec in the child, so the restriction is inherited by the exec'd
|
||||||
|
//! program — Landlock domains survive `execve`).
|
||||||
|
//!
|
||||||
|
//! # Per-access-class handling (mirrors the LP4-0 compile invariant)
|
||||||
|
//!
|
||||||
|
//! Landlock works by *handling* a set of access rights — everything **not** granted
|
||||||
|
//! is then denied **for those handled rights only**. We therefore handle a right
|
||||||
|
//! class **only if the plan actually posed a grant of that class**:
|
||||||
|
//!
|
||||||
|
//! - some `RO` grant present ⇒ handle the **read** class (`from_read`): reads become
|
||||||
|
//! restricted to the granted roots;
|
||||||
|
//! - some `RW` grant present ⇒ handle the **write** class (`from_write`): writes /
|
||||||
|
//! create / delete become restricted to the granted roots.
|
||||||
|
//!
|
||||||
|
//! A write-only policy thus leaves **reads globally unrestricted** (so the agent can
|
||||||
|
//! still load `libc`, read `/etc`, …) and only fences writes — exactly the autonomy
|
||||||
|
//! the per-access-class compile guarantees. The flip side: a policy that restricts
|
||||||
|
//! reads governs *all* reads, so the compiled plan must include the system read
|
||||||
|
//! paths the program needs; that enrichment is the launch-path's concern (LP4-2),
|
||||||
|
//! not this adapter's — here we translate the plan faithfully.
|
||||||
|
//!
|
||||||
|
//! # Fallback policy
|
||||||
|
//!
|
||||||
|
//! On a kernel/ABI without (sufficient) Landlock, `restrict_self` reports
|
||||||
|
//! [`RulesetStatus::NotEnforced`]. We then apply the product fallback: **fail-open**
|
||||||
|
//! ([`SandboxStatus::Unsupported`]) for any posture **except** [`Posture::Deny`],
|
||||||
|
//! where we **fail-closed** with [`SandboxError::KernelTooOld`] (a `Deny` posture
|
||||||
|
//! cannot be silently dropped). Partial enforcement (older ABI missing some rights)
|
||||||
|
//! maps to [`SandboxStatus::Degraded`].
|
||||||
|
|
||||||
|
use domain::sandbox::{
|
||||||
|
PathAccess, SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus,
|
||||||
|
};
|
||||||
|
use domain::Posture;
|
||||||
|
|
||||||
|
// Leading `::` selects the extern `landlock` crate (this module is also named
|
||||||
|
// `landlock`, so the disambiguation matters).
|
||||||
|
use ::landlock::{
|
||||||
|
path_beneath_rules, AccessFs, BitFlags, CompatLevel, Compatible, Ruleset, RulesetAttr,
|
||||||
|
RulesetCreatedAttr, RulesetStatus, ABI,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The Landlock ABI we author rules against. `ABI::V1` (Linux ≥ 5.13) is the broad
|
||||||
|
/// floor; `CompatLevel::BestEffort` lets newer kernels run it unchanged and older /
|
||||||
|
/// absent support degrade rather than hard-error.
|
||||||
|
const TARGET_ABI: ABI = ABI::V1;
|
||||||
|
|
||||||
|
/// The Linux Landlock [`SandboxEnforcer`].
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct LandlockSandbox;
|
||||||
|
|
||||||
|
impl LandlockSandbox {
|
||||||
|
/// Builds the Landlock enforcer.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Landlock access rights a single grant opens, intersected later with the
|
||||||
|
/// handled set. An `RW` grant also opens the read rights (writing a tree usually
|
||||||
|
/// implies reading it) — harmless when reads are ungoverned, correct when they are.
|
||||||
|
fn grant_access(access: PathAccess, abi: ABI) -> BitFlags<AccessFs> {
|
||||||
|
let mut acc = BitFlags::<AccessFs>::empty();
|
||||||
|
if access.contains(PathAccess::RO) {
|
||||||
|
acc |= AccessFs::from_read(abi);
|
||||||
|
}
|
||||||
|
if access.contains(PathAccess::RW) {
|
||||||
|
acc |= AccessFs::from_read(abi) | AccessFs::from_write(abi);
|
||||||
|
}
|
||||||
|
if access.contains(PathAccess::EXEC) {
|
||||||
|
// The domain never emits EXEC today; honoured for completeness.
|
||||||
|
acc |= AccessFs::Execute;
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SandboxEnforcer for LandlockSandbox {
|
||||||
|
fn kind(&self) -> SandboxKind {
|
||||||
|
SandboxKind::Landlock
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enforce(&self, plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError> {
|
||||||
|
let abi = TARGET_ABI;
|
||||||
|
|
||||||
|
// Landlock is an **additive allowlist** — it can only *remove* access, i.e.
|
||||||
|
// it is deny-by-default. A `Posture::Allow` fallback means the opposite,
|
||||||
|
// **allow-by-default**: paths matched by no grant must stay reachable. An
|
||||||
|
// allowlist cannot express that (it would lock everything down to the granted
|
||||||
|
// roots, closing the CLI binary, its libs and `~/.<cli>` home — the very
|
||||||
|
// "failed to open terminal" symptom). So under an Allow fallback we enforce
|
||||||
|
// nothing: the policy's only teeth are explicit Denies, which are deny-islands
|
||||||
|
// under an allow-all that an additive sandbox cannot carve out — they remain
|
||||||
|
// advisory via the LP3 projection. This is a property of the allowlist
|
||||||
|
// mechanism, hence it lives in the adapter, not the pure plan.
|
||||||
|
if plan.default_posture == Posture::Allow {
|
||||||
|
return Ok(SandboxStatus::Enforced);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle a right class only if the plan posed a grant of that class, so an
|
||||||
|
// unposed class stays globally unrestricted (per-access-class autonomy).
|
||||||
|
let mut handled = BitFlags::<AccessFs>::empty();
|
||||||
|
for grant in &plan.allowed {
|
||||||
|
if grant.access.contains(PathAccess::RO) {
|
||||||
|
handled |= AccessFs::from_read(abi);
|
||||||
|
}
|
||||||
|
if grant.access.contains(PathAccess::RW) {
|
||||||
|
handled |= AccessFs::from_write(abi);
|
||||||
|
}
|
||||||
|
if grant.access.contains(PathAccess::EXEC) {
|
||||||
|
handled |= AccessFs::Execute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if handled.is_empty() {
|
||||||
|
// No file restriction to apply (empty / bash-only plan): nothing for
|
||||||
|
// Landlock to enforce. A `Deny` posture's teeth, if any, then live only
|
||||||
|
// in the advisory LP3 projection — not this adapter's concern.
|
||||||
|
return Ok(SandboxStatus::Enforced);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ruleset = Ruleset::default()
|
||||||
|
.set_compatibility(CompatLevel::BestEffort)
|
||||||
|
.handle_access(handled)
|
||||||
|
.and_then(Ruleset::create)
|
||||||
|
.map_err(|e| SandboxError::Ruleset(e.to_string()))?;
|
||||||
|
|
||||||
|
for grant in &plan.allowed {
|
||||||
|
let access = grant_access(grant.access, abi) & handled;
|
||||||
|
if access.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// `path_beneath_rules` silently skips a path it cannot open (a grant on
|
||||||
|
// a not-yet-existing root just adds no rule — best-effort, never fatal).
|
||||||
|
ruleset = ruleset
|
||||||
|
.add_rules(path_beneath_rules([grant.abs_root.as_str()], access))
|
||||||
|
.map_err(|e: ::landlock::RulesetError| SandboxError::Ruleset(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = ruleset
|
||||||
|
.restrict_self()
|
||||||
|
.map_err(|e| SandboxError::Ruleset(e.to_string()))?;
|
||||||
|
|
||||||
|
status_from_ruleset(status.ruleset, plan.default_posture)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps the kernel's [`RulesetStatus`] (after `restrict_self`) plus the plan's
|
||||||
|
/// fallback [`Posture`] to the domain outcome. **Pure** (no I/O, no kernel): the
|
||||||
|
/// security-critical decision — including the *fail-closed only under `Deny`*
|
||||||
|
/// branch — lives here so it is unit-testable without a Landlock-incapable kernel.
|
||||||
|
///
|
||||||
|
/// - [`RulesetStatus::FullyEnforced`] ⇒ `Ok(`[`SandboxStatus::Enforced`]`)`
|
||||||
|
/// - [`RulesetStatus::PartiallyEnforced`] ⇒ `Ok(`[`SandboxStatus::Degraded`]`)`
|
||||||
|
/// - [`RulesetStatus::NotEnforced`] + [`Posture::Deny`] ⇒
|
||||||
|
/// `Err(`[`SandboxError::KernelTooOld`]`)` (fail-closed)
|
||||||
|
/// - [`RulesetStatus::NotEnforced`] + any other posture ⇒
|
||||||
|
/// `Ok(`[`SandboxStatus::Unsupported`]`)` (fail-open + warning)
|
||||||
|
fn status_from_ruleset(
|
||||||
|
status: RulesetStatus,
|
||||||
|
posture: Posture,
|
||||||
|
) -> Result<SandboxStatus, SandboxError> {
|
||||||
|
match status {
|
||||||
|
RulesetStatus::FullyEnforced => Ok(SandboxStatus::Enforced),
|
||||||
|
RulesetStatus::PartiallyEnforced => Ok(SandboxStatus::Degraded(
|
||||||
|
"landlock ABI compatibility reduced the enforced access rights (best-effort)"
|
||||||
|
.to_owned(),
|
||||||
|
)),
|
||||||
|
RulesetStatus::NotEnforced => {
|
||||||
|
if posture == Posture::Deny {
|
||||||
|
Err(SandboxError::KernelTooOld(
|
||||||
|
"landlock unavailable on this kernel but the policy posture is Deny \
|
||||||
|
(fail-closed)"
|
||||||
|
.to_owned(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(SandboxStatus::Unsupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::sandbox::{PathGrant, SandboxPlan};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// A unique temp dir for one test (no external tempfile dep).
|
||||||
|
fn fresh_dir(tag: &str) -> PathBuf {
|
||||||
|
let nanos = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let p = std::env::temp_dir().join(format!("idea-landlock-{tag}-{nanos}"));
|
||||||
|
std::fs::create_dir_all(&p).unwrap();
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real-kernel integration test: a write-only plan that grants RW on one dir
|
||||||
|
/// must let writes there succeed and block writes elsewhere with EACCES, while
|
||||||
|
/// leaving reads unrestricted. Runs the enforcement on a dedicated thread (the
|
||||||
|
/// restriction is irreversible for that thread) and **skips** if the running
|
||||||
|
/// kernel has no Landlock (CI / old kernels), like the SSH/WSL gated tests.
|
||||||
|
#[test]
|
||||||
|
fn landlock_write_only_plan_fences_writes_to_the_grant() {
|
||||||
|
let allowed = fresh_dir("allowed");
|
||||||
|
let denied = fresh_dir("denied");
|
||||||
|
let allowed_for_thread = allowed.clone();
|
||||||
|
let denied_for_thread = denied.clone();
|
||||||
|
|
||||||
|
let plan = SandboxPlan {
|
||||||
|
allowed: vec![PathGrant {
|
||||||
|
abs_root: allowed.to_string_lossy().into_owned(),
|
||||||
|
access: PathAccess::RW,
|
||||||
|
}],
|
||||||
|
default_posture: Posture::Ask,
|
||||||
|
};
|
||||||
|
|
||||||
|
let outcome = std::thread::spawn(move || {
|
||||||
|
let status = LandlockSandbox::new()
|
||||||
|
.enforce(&plan)
|
||||||
|
.expect("enforce must not error under an Ask posture");
|
||||||
|
// The thread is now sandboxed: try a write inside and outside the grant.
|
||||||
|
let ok_write = std::fs::write(allowed_for_thread.join("inside.txt"), b"hi");
|
||||||
|
let ko_write = std::fs::write(denied_for_thread.join("outside.txt"), b"nope");
|
||||||
|
(status, ok_write, ko_write.map_err(|e| e.kind()))
|
||||||
|
})
|
||||||
|
.join()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (status, ok_write, ko_write) = outcome;
|
||||||
|
|
||||||
|
if status == SandboxStatus::Unsupported {
|
||||||
|
eprintln!("skipping: Landlock not available on this kernel");
|
||||||
|
// Clean up best-effort and bail (no enforcement happened).
|
||||||
|
let _ = std::fs::remove_dir_all(&allowed);
|
||||||
|
let _ = std::fs::remove_dir_all(&denied);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(status, SandboxStatus::Enforced | SandboxStatus::Degraded(_)),
|
||||||
|
"expected an enforced/degraded sandbox, got {status:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ok_write.is_ok(),
|
||||||
|
"write inside the granted root must succeed, got {ok_write:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ko_write,
|
||||||
|
Err(std::io::ErrorKind::PermissionDenied),
|
||||||
|
"write outside the granted root must be blocked (EACCES)"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&allowed);
|
||||||
|
let _ = std::fs::remove_dir_all(&denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty plan (no file grant) has nothing to enforce ⇒ `Enforced`, no error.
|
||||||
|
#[test]
|
||||||
|
fn empty_plan_is_a_noop_enforced() {
|
||||||
|
let plan = SandboxPlan {
|
||||||
|
allowed: vec![],
|
||||||
|
default_posture: Posture::Ask,
|
||||||
|
};
|
||||||
|
// Run on a throwaway thread to avoid restricting the test runner thread.
|
||||||
|
let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan))
|
||||||
|
.join()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status, SandboxStatus::Enforced);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Bash-only / empty plan under a `Deny` posture.** A bash-only policy compiles
|
||||||
|
/// (LP4-0) to an empty `allowed`; even combined with `Posture::Deny` the adapter
|
||||||
|
/// has no file right to *handle*, so it must early-return `Enforced` WITHOUT
|
||||||
|
/// restricting anything — and crucially WITHOUT `KernelTooOld`: the fail-closed
|
||||||
|
/// branch is only ever reached once a class was actually handled.
|
||||||
|
#[test]
|
||||||
|
fn empty_plan_under_deny_posture_is_enforced_without_restriction() {
|
||||||
|
let plan = SandboxPlan {
|
||||||
|
allowed: vec![],
|
||||||
|
default_posture: Posture::Deny,
|
||||||
|
};
|
||||||
|
let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan))
|
||||||
|
.join()
|
||||||
|
.unwrap()
|
||||||
|
.expect("an empty plan never fails closed (nothing handled)");
|
||||||
|
assert_eq!(status, SandboxStatus::Enforced);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Allow fallback ⇒ no OS restriction even with grants posed.** Setting the
|
||||||
|
/// policy to allow-by-default (and, in the UI, every option to Allow) emits RO|RW
|
||||||
|
/// grants scoped to the project root. An allowlist sandbox would then close every
|
||||||
|
/// read/write OUTSIDE that root — locking out the CLI binary, its libs and home,
|
||||||
|
/// the exact "failed to open terminal" bug. Under `Posture::Allow` the adapter must
|
||||||
|
/// instead enforce nothing: from the (would-be) sandboxed thread, a read AND a
|
||||||
|
/// write OUTSIDE the only grant must both still succeed.
|
||||||
|
#[test]
|
||||||
|
fn allow_fallback_does_not_restrict_even_with_grants() {
|
||||||
|
let granted = fresh_dir("allow-granted");
|
||||||
|
let outside = fresh_dir("allow-outside");
|
||||||
|
std::fs::write(outside.join("pre.txt"), b"pre").unwrap();
|
||||||
|
let outside_t = outside.clone();
|
||||||
|
|
||||||
|
let plan = SandboxPlan {
|
||||||
|
allowed: vec![PathGrant {
|
||||||
|
abs_root: granted.to_string_lossy().into_owned(),
|
||||||
|
access: PathAccess::RO.union(PathAccess::RW),
|
||||||
|
}],
|
||||||
|
default_posture: Posture::Allow,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (status, read_outside, write_outside) = std::thread::spawn(move || {
|
||||||
|
let status = LandlockSandbox::new()
|
||||||
|
.enforce(&plan)
|
||||||
|
.expect("an Allow fallback never fails closed");
|
||||||
|
let read = std::fs::read(outside_t.join("pre.txt"));
|
||||||
|
let write =
|
||||||
|
std::fs::write(outside_t.join("new.txt"), b"x").map_err(|e| e.kind());
|
||||||
|
(status, read, write)
|
||||||
|
})
|
||||||
|
.join()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
SandboxStatus::Enforced,
|
||||||
|
"Allow fallback is a no-op enforcement, not an error or a lock-down"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
read_outside.is_ok(),
|
||||||
|
"Allow fallback must keep reads outside the grant open, got {read_outside:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
write_outside,
|
||||||
|
Ok(()),
|
||||||
|
"Allow fallback must keep writes outside the grant open"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&granted);
|
||||||
|
let _ = std::fs::remove_dir_all(&outside);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **CRITICAL SAFETY PROPERTY — IdeA is never sandboxed.** The adapter restricts
|
||||||
|
/// the *current thread*; the launch path runs it on a throwaway thread. This test
|
||||||
|
/// proves the Landlock domain is confined to that throwaway thread and does **not**
|
||||||
|
/// leak onto the thread that spawned it (which simulates the IdeA process): after
|
||||||
|
/// the child has sandboxed itself, the parent thread must still read/write OUTSIDE
|
||||||
|
/// the plan's roots. A leak here would be catastrophic (IdeA itself locked down).
|
||||||
|
#[test]
|
||||||
|
fn enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed() {
|
||||||
|
let granted = fresh_dir("safety-granted");
|
||||||
|
let outside = fresh_dir("safety-outside");
|
||||||
|
let granted_t = granted.clone();
|
||||||
|
let outside_t = outside.clone();
|
||||||
|
|
||||||
|
let plan = SandboxPlan {
|
||||||
|
allowed: vec![PathGrant {
|
||||||
|
abs_root: granted.to_string_lossy().into_owned(),
|
||||||
|
access: PathAccess::RW,
|
||||||
|
}],
|
||||||
|
default_posture: Posture::Ask,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enforce on a dedicated thread, exactly as the launch path does, and report
|
||||||
|
// whether the sandbox was actually live there (write outside must be blocked).
|
||||||
|
let (status, child_outside_write) = std::thread::spawn(move || {
|
||||||
|
let status = LandlockSandbox::new()
|
||||||
|
.enforce(&plan)
|
||||||
|
.expect("enforce must not error under an Ask posture");
|
||||||
|
let _ = granted_t; // (writable in the child; not asserted here)
|
||||||
|
let child_outside =
|
||||||
|
std::fs::write(outside_t.join("from-child.txt"), b"x").map_err(|e| e.kind());
|
||||||
|
(status, child_outside)
|
||||||
|
})
|
||||||
|
.join()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if status == SandboxStatus::Unsupported {
|
||||||
|
eprintln!("skipping: Landlock not available on this kernel");
|
||||||
|
let _ = std::fs::remove_dir_all(&granted);
|
||||||
|
let _ = std::fs::remove_dir_all(&outside);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity: the sandbox really was active on the throwaway thread.
|
||||||
|
assert_eq!(
|
||||||
|
child_outside_write,
|
||||||
|
Err(std::io::ErrorKind::PermissionDenied),
|
||||||
|
"the throwaway thread must be sandboxed (write outside the grant blocked)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// THE GUARANTEE: the parent thread (≈ the IdeA process) is not a descendant of
|
||||||
|
// the throwaway thread, so the domain must not have leaked onto it.
|
||||||
|
let idea_write = std::fs::write(outside.join("from-idea.txt"), b"idea");
|
||||||
|
assert!(
|
||||||
|
idea_write.is_ok(),
|
||||||
|
"SAFETY VIOLATION: IdeA's own thread was sandboxed — write outside the plan \
|
||||||
|
failed ({idea_write:?}); the Landlock domain leaked off the throwaway thread"
|
||||||
|
);
|
||||||
|
let idea_read = std::fs::read(outside.join("from-idea.txt"));
|
||||||
|
assert!(
|
||||||
|
idea_read.is_ok(),
|
||||||
|
"IdeA must still read outside the plan roots, got {idea_read:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&granted);
|
||||||
|
let _ = std::fs::remove_dir_all(&outside);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **READ (RO) dimension.** A plan with a single RO grant: from the sandboxed
|
||||||
|
/// child, reading a file UNDER the granted root succeeds, while reading a file
|
||||||
|
/// OUTSIDE is denied (EACCES). This also documents the behaviour DevBackend
|
||||||
|
/// flagged: as soon as *any* RO grant is posed the read class is handled, so
|
||||||
|
/// **every** read outside the granted roots is closed — a realistic read-restricted
|
||||||
|
/// plan must therefore include the needed system paths (an LP4-2 concern).
|
||||||
|
#[test]
|
||||||
|
fn read_only_plan_fences_reads_to_the_grant() {
|
||||||
|
let granted = fresh_dir("ro-granted");
|
||||||
|
let outside = fresh_dir("ro-outside");
|
||||||
|
// Files must exist before enforcement (creation is a write).
|
||||||
|
std::fs::write(granted.join("in.txt"), b"inside").unwrap();
|
||||||
|
std::fs::write(outside.join("out.txt"), b"outside").unwrap();
|
||||||
|
let granted_t = granted.clone();
|
||||||
|
let outside_t = outside.clone();
|
||||||
|
|
||||||
|
let plan = SandboxPlan {
|
||||||
|
allowed: vec![PathGrant {
|
||||||
|
abs_root: granted.to_string_lossy().into_owned(),
|
||||||
|
access: PathAccess::RO,
|
||||||
|
}],
|
||||||
|
default_posture: Posture::Ask,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (status, ok_read, ko_read) = std::thread::spawn(move || {
|
||||||
|
let status = LandlockSandbox::new()
|
||||||
|
.enforce(&plan)
|
||||||
|
.expect("enforce must not error under an Ask posture");
|
||||||
|
let ok = std::fs::read(granted_t.join("in.txt"));
|
||||||
|
let ko = std::fs::read(outside_t.join("out.txt")).map_err(|e| e.kind());
|
||||||
|
(status, ok, ko)
|
||||||
|
})
|
||||||
|
.join()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if status == SandboxStatus::Unsupported {
|
||||||
|
eprintln!("skipping: Landlock not available on this kernel");
|
||||||
|
let _ = std::fs::remove_dir_all(&granted);
|
||||||
|
let _ = std::fs::remove_dir_all(&outside);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(status, SandboxStatus::Enforced | SandboxStatus::Degraded(_)),
|
||||||
|
"expected an enforced/degraded sandbox, got {status:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ok_read.is_ok(),
|
||||||
|
"read inside the granted RO root must succeed, got {ok_read:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ko_read,
|
||||||
|
Err(std::io::ErrorKind::PermissionDenied),
|
||||||
|
"once a RO grant is posed, reads outside the granted roots are closed (EACCES)"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&granted);
|
||||||
|
let _ = std::fs::remove_dir_all(&outside);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
crates/infrastructure/src/sandbox/mod.rs
Normal file
82
crates/infrastructure/src/sandbox/mod.rs
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
//! OS-sandbox adapters (lot LP4-1) — concrete [`domain::sandbox::SandboxEnforcer`]
|
||||||
|
//! implementations and the cfg-selected default constructor.
|
||||||
|
//!
|
||||||
|
//! - [`LandlockSandbox`] (Linux only) enforces a [`domain::sandbox::SandboxPlan`]
|
||||||
|
//! via the kernel Landlock LSM.
|
||||||
|
//! - [`NoopSandbox`] (everywhere) enforces nothing — the non-Linux / fallback path.
|
||||||
|
//!
|
||||||
|
//! [`default_enforcer`] returns the right one for the build target as a
|
||||||
|
//! `Arc<dyn SandboxEnforcer>`, ready for the composition root (LP4-3) to inject.
|
||||||
|
//! Nothing here is wired into the launch path yet (that is LP4-2): until then the
|
||||||
|
//! enforcer stays unused and `SpawnSpec.sandbox` stays `None`, so there is zero
|
||||||
|
//! runtime behaviour change.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::sandbox::SandboxEnforcer;
|
||||||
|
|
||||||
|
mod noop;
|
||||||
|
pub use noop::NoopSandbox;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod landlock;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub use landlock::LandlockSandbox;
|
||||||
|
|
||||||
|
/// The default OS-sandbox enforcer for the current build target: [`LandlockSandbox`]
|
||||||
|
/// on Linux, [`NoopSandbox`] everywhere else.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[must_use]
|
||||||
|
pub fn default_enforcer() -> Arc<dyn SandboxEnforcer> {
|
||||||
|
Arc::new(LandlockSandbox::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default OS-sandbox enforcer for the current build target: [`NoopSandbox`]
|
||||||
|
/// on non-Linux platforms (no OS sandbox wired yet).
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
#[must_use]
|
||||||
|
pub fn default_enforcer() -> Arc<dyn SandboxEnforcer> {
|
||||||
|
Arc::new(NoopSandbox::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::sandbox::{SandboxKind, SandboxPlan, SandboxStatus};
|
||||||
|
use domain::Posture;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noop_enforcer_is_unsupported_and_never_errors() {
|
||||||
|
use domain::sandbox::PathGrant;
|
||||||
|
|
||||||
|
let noop = NoopSandbox::new();
|
||||||
|
assert_eq!(noop.kind(), SandboxKind::Unsupported);
|
||||||
|
|
||||||
|
// Across every posture — and even with grants present — the no-op enforces
|
||||||
|
// nothing and never fails closed: there is no OS sandbox here to fail closed
|
||||||
|
// against (the Deny posture's teeth live only in the advisory LP3 projection).
|
||||||
|
for posture in [Posture::Allow, Posture::Ask, Posture::Deny] {
|
||||||
|
let plan = SandboxPlan {
|
||||||
|
allowed: vec![PathGrant {
|
||||||
|
abs_root: "/whatever".to_owned(),
|
||||||
|
access: domain::sandbox::PathAccess::RW,
|
||||||
|
}],
|
||||||
|
default_posture: posture,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
noop.enforce(&plan).unwrap(),
|
||||||
|
SandboxStatus::Unsupported,
|
||||||
|
"no-op must return Unsupported (never Err) under posture {posture:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_enforcer_matches_the_build_target() {
|
||||||
|
let enforcer = default_enforcer();
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
assert_eq!(enforcer.kind(), SandboxKind::Landlock);
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
assert_eq!(enforcer.kind(), SandboxKind::Unsupported);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
crates/infrastructure/src/sandbox/noop.rs
Normal file
33
crates/infrastructure/src/sandbox/noop.rs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
//! [`NoopSandbox`] — the no-op [`SandboxEnforcer`] for platforms without an OS
|
||||||
|
//! sandbox (Windows, macOS) and the universal fallback (lot LP4-1).
|
||||||
|
//!
|
||||||
|
//! It compiles **everywhere** and never restricts anything: `enforce` always
|
||||||
|
//! returns [`SandboxStatus::Unsupported`] and never errors. The caller keeps the
|
||||||
|
//! advisory LP3 projection as its only guard.
|
||||||
|
|
||||||
|
use domain::sandbox::{SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus};
|
||||||
|
|
||||||
|
/// A [`SandboxEnforcer`] that enforces nothing (non-Linux / fallback).
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct NoopSandbox;
|
||||||
|
|
||||||
|
impl NoopSandbox {
|
||||||
|
/// Builds the no-op enforcer.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SandboxEnforcer for NoopSandbox {
|
||||||
|
fn kind(&self) -> SandboxKind {
|
||||||
|
SandboxKind::Unsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enforce(&self, _plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError> {
|
||||||
|
// No OS sandbox here: nothing enforced, never an error (the fail-closed
|
||||||
|
// posture handling is a Landlock-only concern; on an unsupported platform
|
||||||
|
// there is nothing to fail closed *against*).
|
||||||
|
Ok(SandboxStatus::Unsupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,12 +13,13 @@
|
|||||||
//! (2026-06-09) ; **seule [`parse_event`] (et la composition de la commande) porte
|
//! (2026-06-09) ; **seule [`parse_event`] (et la composition de la commande) porte
|
||||||
//! le format**, pas la machinerie ni le reste de l'adapter.
|
//! le format**, pas la machinerie ni le reste de l'adapter.
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
||||||
|
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||||
use domain::SessionId;
|
use domain::SessionId;
|
||||||
|
|
||||||
use super::process::{run_turn, SpawnLine};
|
use super::process::{run_turn, SpawnLine};
|
||||||
@ -154,24 +155,36 @@ pub struct ClaudeSdkSession {
|
|||||||
cwd: String,
|
cwd: String,
|
||||||
/// Id de conversation **du moteur** Claude, capté au premier tour, `None` avant.
|
/// Id de conversation **du moteur** Claude, capté au premier tour, `None` avant.
|
||||||
conversation_id: Mutex<Option<String>>,
|
conversation_id: Mutex<Option<String>>,
|
||||||
|
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
|
||||||
|
/// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif).
|
||||||
|
sandbox: Option<SandboxPlan>,
|
||||||
|
/// Enforcer OS **par instance** (lot LP4-4), passé à [`run_turn`]. `None` ⇒ pas
|
||||||
|
/// de sandboxing même si un plan est présent (cohérent avec le chemin PTY).
|
||||||
|
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClaudeSdkSession {
|
impl ClaudeSdkSession {
|
||||||
/// Construit l'adapter. `command` est le binaire à lancer (injecté ⇒ testable
|
/// Construit l'adapter. `command` est le binaire à lancer (injecté ⇒ testable
|
||||||
/// avec un fake CLI) ; `seed_conversation_id` amorce la reprise (`SessionPlan::
|
/// avec un fake CLI) ; `seed_conversation_id` amorce la reprise (`SessionPlan::
|
||||||
/// Resume` côté factory) ou reste `None` pour une conversation neuve.
|
/// Resume` côté factory) ou reste `None` pour une conversation neuve. `sandbox` /
|
||||||
|
/// `sandbox_enforcer` (lot LP4-4) pilotent le sandboxing OS du tour ; `None`/`None`
|
||||||
|
/// ⇒ chemin natif inchangé.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
id: SessionId,
|
id: SessionId,
|
||||||
command: impl Into<String>,
|
command: impl Into<String>,
|
||||||
cwd: impl Into<String>,
|
cwd: impl Into<String>,
|
||||||
seed_conversation_id: Option<String>,
|
seed_conversation_id: Option<String>,
|
||||||
|
sandbox: Option<SandboxPlan>,
|
||||||
|
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
cwd: cwd.into(),
|
cwd: cwd.into(),
|
||||||
conversation_id: Mutex::new(seed_conversation_id),
|
conversation_id: Mutex::new(seed_conversation_id),
|
||||||
|
sandbox,
|
||||||
|
sandbox_enforcer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,6 +214,7 @@ impl ClaudeSdkSession {
|
|||||||
cwd: self.cwd.clone(),
|
cwd: self.cwd.clone(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
stdin: None,
|
stdin: None,
|
||||||
|
sandbox: self.sandbox.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -217,7 +231,7 @@ impl AgentSession for ClaudeSdkSession {
|
|||||||
|
|
||||||
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||||
let spec = self.build_spawn_line(prompt);
|
let spec = self.build_spawn_line(prompt);
|
||||||
let raw_lines = run_turn(&spec, None).await?;
|
let raw_lines = run_turn(&spec, None, self.sandbox_enforcer.as_ref()).await?;
|
||||||
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
let mut captured_id = None;
|
let mut captured_id = None;
|
||||||
|
|||||||
@ -10,12 +10,13 @@
|
|||||||
//! le format. **Seule [`parse_event`] (et la composition de la commande) porte le
|
//! le format. **Seule [`parse_event`] (et la composition de la commande) porte le
|
||||||
//! format Codex** ; la machinerie reste inchangée.
|
//! format Codex** ; la machinerie reste inchangée.
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
||||||
|
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||||
use domain::SessionId;
|
use domain::SessionId;
|
||||||
|
|
||||||
use super::process::{run_turn, SpawnLine};
|
use super::process::{run_turn, SpawnLine};
|
||||||
@ -123,23 +124,35 @@ pub struct CodexExecSession {
|
|||||||
cwd: String,
|
cwd: String,
|
||||||
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
|
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
|
||||||
conversation_id: Mutex<Option<String>>,
|
conversation_id: Mutex<Option<String>>,
|
||||||
|
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
|
||||||
|
/// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif).
|
||||||
|
sandbox: Option<SandboxPlan>,
|
||||||
|
/// Enforcer OS **par instance** (lot LP4-4), passé à [`run_turn`]. `None` ⇒ pas
|
||||||
|
/// de sandboxing même si un plan est présent (cohérent avec le chemin PTY).
|
||||||
|
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CodexExecSession {
|
impl CodexExecSession {
|
||||||
/// Construit l'adapter. `command` est injecté (⇒ testable avec un fake CLI) ;
|
/// Construit l'adapter. `command` est injecté (⇒ testable avec un fake CLI) ;
|
||||||
/// `seed_conversation_id` amorce la reprise ou reste `None` (conversation neuve).
|
/// `seed_conversation_id` amorce la reprise ou reste `None` (conversation neuve).
|
||||||
|
/// `sandbox` / `sandbox_enforcer` (lot LP4-4) pilotent le sandboxing OS ;
|
||||||
|
/// `None`/`None` ⇒ chemin natif inchangé.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
id: SessionId,
|
id: SessionId,
|
||||||
command: impl Into<String>,
|
command: impl Into<String>,
|
||||||
cwd: impl Into<String>,
|
cwd: impl Into<String>,
|
||||||
seed_conversation_id: Option<String>,
|
seed_conversation_id: Option<String>,
|
||||||
|
sandbox: Option<SandboxPlan>,
|
||||||
|
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
cwd: cwd.into(),
|
cwd: cwd.into(),
|
||||||
conversation_id: Mutex::new(seed_conversation_id),
|
conversation_id: Mutex::new(seed_conversation_id),
|
||||||
|
sandbox,
|
||||||
|
sandbox_enforcer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,6 +189,7 @@ impl CodexExecSession {
|
|||||||
cwd: self.cwd.clone(),
|
cwd: self.cwd.clone(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
stdin: None,
|
stdin: None,
|
||||||
|
sandbox: self.sandbox.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -192,7 +206,7 @@ impl AgentSession for CodexExecSession {
|
|||||||
|
|
||||||
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||||
let spec = self.build_spawn_line(prompt);
|
let spec = self.build_spawn_line(prompt);
|
||||||
let raw_lines = run_turn(&spec, None).await?;
|
let raw_lines = run_turn(&spec, None, self.sandbox_enforcer.as_ref()).await?;
|
||||||
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
let mut captured_id = None;
|
let mut captured_id = None;
|
||||||
|
|||||||
@ -84,6 +84,7 @@ impl FakeCli {
|
|||||||
cwd: "/".to_owned(),
|
cwd: "/".to_owned(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
stdin: None,
|
stdin: None,
|
||||||
|
sandbox: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ use domain::ports::{
|
|||||||
};
|
};
|
||||||
use domain::profile::{AgentProfile, StructuredAdapter};
|
use domain::profile::{AgentProfile, StructuredAdapter};
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
|
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||||
use domain::SessionId;
|
use domain::SessionId;
|
||||||
|
|
||||||
use super::claude::ClaudeSdkSession;
|
use super::claude::ClaudeSdkSession;
|
||||||
@ -23,17 +24,36 @@ use super::codex::CodexExecSession;
|
|||||||
|
|
||||||
/// Fabrique infra des sessions structurées, sélectionnée par le profil.
|
/// Fabrique infra des sessions structurées, sélectionnée par le profil.
|
||||||
///
|
///
|
||||||
/// Sans état : elle instancie l'adapter au vol depuis le profil (le binaire à
|
/// Quasi sans état : elle instancie l'adapter au vol depuis le profil (le binaire à
|
||||||
/// lancer = `profile.command`), de sorte qu'un seul exemplaire injecté au
|
/// lancer = `profile.command`), de sorte qu'un seul exemplaire injecté au
|
||||||
/// composition root sert tous les agents (jumeau de `CliAgentRuntime`).
|
/// composition root sert tous les agents (jumeau de `CliAgentRuntime`). Le seul état
|
||||||
#[derive(Debug, Default, Clone, Copy)]
|
/// porté est l'**enforcer de sandbox OS** optionnel (lot LP4-4), injecté **par
|
||||||
pub struct StructuredSessionFactory;
|
/// instance** au composition root (jumeau de `PortablePtyAdapter::with_sandbox_enforcer`)
|
||||||
|
/// et apparié au plan **par lancement** dans [`start`](AgentSessionFactory::start).
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct StructuredSessionFactory {
|
||||||
|
/// Enforcer OS optionnel passé aux adapters structurés. `None` ⇒ aucun
|
||||||
|
/// sandboxing (chemin natif inchangé, zéro régression).
|
||||||
|
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||||
|
}
|
||||||
|
|
||||||
impl StructuredSessionFactory {
|
impl StructuredSessionFactory {
|
||||||
/// Construit la fabrique.
|
/// Construit la fabrique (sans enforcer : chemin natif).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self
|
Self {
|
||||||
|
sandbox_enforcer: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builder additif : câble un [`SandboxEnforcer`] OS (lot LP4-4). Jumeau exact de
|
||||||
|
/// [`crate::PortablePtyAdapter::with_sandbox_enforcer`]. Avec lui, tout lancement
|
||||||
|
/// structuré dont le plan (`SpawnSpec.sandbox`) est `Some` voit ce plan appliqué
|
||||||
|
/// sur l'enfant. Sans lui (défaut), aucun tour n'est sandboxé.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_sandbox_enforcer(mut self, enforcer: Arc<dyn SandboxEnforcer>) -> Self {
|
||||||
|
self.sandbox_enforcer = Some(enforcer);
|
||||||
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,6 +82,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
|||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
cwd: &ProjectPath,
|
cwd: &ProjectPath,
|
||||||
session: &SessionPlan,
|
session: &SessionPlan,
|
||||||
|
sandbox: Option<&SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||||
let adapter = profile.structured_adapter.ok_or_else(|| {
|
let adapter = profile.structured_adapter.ok_or_else(|| {
|
||||||
AgentSessionError::Start(format!(
|
AgentSessionError::Start(format!(
|
||||||
@ -75,14 +96,25 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
|||||||
let cwd = cwd.as_str().to_owned();
|
let cwd = cwd.as_str().to_owned();
|
||||||
let seed = seed_conversation_id(session);
|
let seed = seed_conversation_id(session);
|
||||||
|
|
||||||
|
// Appariement (lot LP4-4) : plan **par lancement** (param) + enforcer **par
|
||||||
|
// instance** (champ). Tous deux sont relayés à l'adapter, qui remplira
|
||||||
|
// `SpawnLine.sandbox` et passera l'enforcer à `run_turn`. `plan == None` ⇒
|
||||||
|
// l'adapter reste sur le drain async natif.
|
||||||
|
let plan = sandbox.cloned();
|
||||||
|
let enforcer = self.sandbox_enforcer.clone();
|
||||||
|
|
||||||
// NOTE : le contexte (`_ctx`) est injecté par `LaunchAgent` (D3) via le
|
// NOTE : le contexte (`_ctx`) est injecté par `LaunchAgent` (D3) via le
|
||||||
// convention file dans le run dir *avant* l'appel à la factory (le `.md` est
|
// convention file dans le run dir *avant* l'appel à la factory (le `.md` est
|
||||||
// déjà écrit) ; l'adapter n'a donc qu'à lancer la CLI dans ce cwd. Aucune
|
// déjà écrit) ; l'adapter n'a donc qu'à lancer la CLI dans ce cwd. Aucune
|
||||||
// injection supplémentaire n'incombe ici en mode structuré (la CLI lit son
|
// injection supplémentaire n'incombe ici en mode structuré (la CLI lit son
|
||||||
// fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd).
|
// fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd).
|
||||||
let session: Arc<dyn AgentSession> = match adapter {
|
let session: Arc<dyn AgentSession> = match adapter {
|
||||||
StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(id, command, cwd, seed)),
|
StructuredAdapter::Claude => {
|
||||||
StructuredAdapter::Codex => Arc::new(CodexExecSession::new(id, command, cwd, seed)),
|
Arc::new(ClaudeSdkSession::new(id, command, cwd, seed, plan, enforcer))
|
||||||
|
}
|
||||||
|
StructuredAdapter::Codex => {
|
||||||
|
Arc::new(CodexExecSession::new(id, command, cwd, seed, plan, enforcer))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,11 @@ pub mod conformance;
|
|||||||
pub mod factory;
|
pub mod factory;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
|
|
||||||
|
/// Tests bout-en-bout de l'enforcement Landlock sur le chemin structuré (lot LP4-4),
|
||||||
|
/// Linux uniquement (pair du module `pty::sandbox_e2e_tests` du lot LP4-3).
|
||||||
|
#[cfg(all(test, target_os = "linux"))]
|
||||||
|
mod sandbox_e2e;
|
||||||
|
|
||||||
pub use claude::ClaudeSdkSession;
|
pub use claude::ClaudeSdkSession;
|
||||||
pub use codex::CodexExecSession;
|
pub use codex::CodexExecSession;
|
||||||
pub use conformance::FakeCli;
|
pub use conformance::FakeCli;
|
||||||
@ -84,7 +89,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn run_turn_drains_every_line_in_order() {
|
async fn run_turn_drains_every_line_in_order() {
|
||||||
let fake = FakeCli::printing(&["ligne-1", "ligne-2", "ligne-3"]);
|
let fake = FakeCli::printing(&["ligne-1", "ligne-2", "ligne-3"]);
|
||||||
let lines = run_turn(&fake.spawn_line(), None)
|
let lines = run_turn(&fake.spawn_line(), None, None)
|
||||||
.await
|
.await
|
||||||
.expect("run_turn réussit");
|
.expect("run_turn réussit");
|
||||||
assert_eq!(lines, vec!["ligne-1", "ligne-2", "ligne-3"]);
|
assert_eq!(lines, vec!["ligne-1", "ligne-2", "ligne-3"]);
|
||||||
@ -98,8 +103,9 @@ mod tests {
|
|||||||
cwd: "/".to_owned(),
|
cwd: "/".to_owned(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
stdin: None,
|
stdin: None,
|
||||||
|
sandbox: None,
|
||||||
};
|
};
|
||||||
let err = run_turn(&spec, None).await.expect_err("doit échouer");
|
let err = run_turn(&spec, None, None).await.expect_err("doit échouer");
|
||||||
assert!(matches!(err, AgentSessionError::Start(_)), "vu: {err:?}");
|
assert!(matches!(err, AgentSessionError::Start(_)), "vu: {err:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,6 +290,8 @@ mod tests {
|
|||||||
fake.command(),
|
fake.command(),
|
||||||
"/",
|
"/",
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
));
|
));
|
||||||
assert_agent_session_contract(session, "claude-conv-1", "réponse Claude").await;
|
assert_agent_session_contract(session, "claude-conv-1", "réponse Claude").await;
|
||||||
}
|
}
|
||||||
@ -296,6 +304,8 @@ mod tests {
|
|||||||
fake.command(),
|
fake.command(),
|
||||||
"/",
|
"/",
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
));
|
));
|
||||||
assert_agent_session_contract(session, "codex-conv-1", "réponse Codex").await;
|
assert_agent_session_contract(session, "codex-conv-1", "réponse Codex").await;
|
||||||
}
|
}
|
||||||
@ -305,7 +315,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stream_is_closed_after_final() {
|
async fn stream_is_closed_after_final() {
|
||||||
let fake = FakeCli::printing(&claude_script());
|
let fake = FakeCli::printing(&claude_script());
|
||||||
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
|
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||||
let stream = session.send("x").await.expect("send ok");
|
let stream = session.send("x").await.expect("send ok");
|
||||||
let events: Vec<_> = stream.collect();
|
let events: Vec<_> = stream.collect();
|
||||||
let after_final = events
|
let after_final = events
|
||||||
@ -323,7 +333,7 @@ mod tests {
|
|||||||
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
|
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
|
||||||
"{ ceci n'est pas du json",
|
"{ ceci n'est pas du json",
|
||||||
]);
|
]);
|
||||||
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
|
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||||
match session.send("x").await {
|
match session.send("x").await {
|
||||||
Err(AgentSessionError::Decode(_)) => {}
|
Err(AgentSessionError::Decode(_)) => {}
|
||||||
Err(other) => panic!("attendu Decode, vu: {other:?}"),
|
Err(other) => panic!("attendu Decode, vu: {other:?}"),
|
||||||
@ -404,7 +414,7 @@ mod tests {
|
|||||||
// Claude : la session démarre et respecte le contrat via le fake CLI.
|
// Claude : la session démarre et respecte le contrat via le fake CLI.
|
||||||
let claude = structured_profile(StructuredAdapter::Claude, &fake.command());
|
let claude = structured_profile(StructuredAdapter::Claude, &fake.command());
|
||||||
let session = factory
|
let session = factory
|
||||||
.start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None)
|
.start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
|
||||||
.await
|
.await
|
||||||
.expect("start Claude ok");
|
.expect("start Claude ok");
|
||||||
let content = drain_final(session.as_ref()).await;
|
let content = drain_final(session.as_ref()).await;
|
||||||
@ -414,7 +424,7 @@ mod tests {
|
|||||||
let fake_cx = FakeCli::printing(&codex_script());
|
let fake_cx = FakeCli::printing(&codex_script());
|
||||||
let codex = structured_profile(StructuredAdapter::Codex, &fake_cx.command());
|
let codex = structured_profile(StructuredAdapter::Codex, &fake_cx.command());
|
||||||
let session_cx = factory
|
let session_cx = factory
|
||||||
.start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None)
|
.start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
|
||||||
.await
|
.await
|
||||||
.expect("start Codex ok");
|
.expect("start Codex ok");
|
||||||
let content_cx = drain_final(session_cx.as_ref()).await;
|
let content_cx = drain_final(session_cx.as_ref()).await;
|
||||||
@ -434,6 +444,7 @@ mod tests {
|
|||||||
&SessionPlan::Resume {
|
&SessionPlan::Resume {
|
||||||
conversation_id: "repris-42".to_owned(),
|
conversation_id: "repris-42".to_owned(),
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("start resume ok");
|
.expect("start resume ok");
|
||||||
@ -525,8 +536,9 @@ mod tests {
|
|||||||
cwd: "/".to_owned(),
|
cwd: "/".to_owned(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
stdin: None,
|
stdin: None,
|
||||||
|
sandbox: None,
|
||||||
};
|
};
|
||||||
let err = run_turn(&spec, Some(Duration::from_millis(50)))
|
let err = run_turn(&spec, Some(Duration::from_millis(50)), None)
|
||||||
.await
|
.await
|
||||||
.expect_err("doit expirer");
|
.expect_err("doit expirer");
|
||||||
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
|
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
|
||||||
@ -757,7 +769,7 @@ mod tests {
|
|||||||
r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"fin"}}"#,
|
r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"fin"}}"#,
|
||||||
r#"{"type":"turn.completed","usage":{}}"#,
|
r#"{"type":"turn.completed","usage":{}}"#,
|
||||||
]);
|
]);
|
||||||
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None);
|
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||||
let events: Vec<_> = s.send("x").await.expect("send").collect();
|
let events: Vec<_> = s.send("x").await.expect("send").collect();
|
||||||
let finals = events
|
let finals = events
|
||||||
.iter()
|
.iter()
|
||||||
@ -785,7 +797,7 @@ mod tests {
|
|||||||
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
|
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
|
||||||
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#,
|
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#,
|
||||||
]);
|
]);
|
||||||
let s = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
|
let s = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||||
let events: Vec<_> = s.send("x").await.expect("send ok").collect();
|
let events: Vec<_> = s.send("x").await.expect("send ok").collect();
|
||||||
let finals = events
|
let finals = events
|
||||||
.iter()
|
.iter()
|
||||||
@ -801,7 +813,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn run_turn_empty_output_is_ok() {
|
async fn run_turn_empty_output_is_ok() {
|
||||||
let fake = FakeCli::printing(&[]);
|
let fake = FakeCli::printing(&[]);
|
||||||
let lines = run_turn(&fake.spawn_line(), None).await.expect("ok");
|
let lines = run_turn(&fake.spawn_line(), None, None).await.expect("ok");
|
||||||
assert!(lines.is_empty());
|
assert!(lines.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -811,7 +823,7 @@ mod tests {
|
|||||||
let fake = FakeCli::printing(&["pong"]);
|
let fake = FakeCli::printing(&["pong"]);
|
||||||
let mut spec = fake.spawn_line();
|
let mut spec = fake.spawn_line();
|
||||||
spec.stdin = Some("ping".to_owned());
|
spec.stdin = Some("ping".to_owned());
|
||||||
let lines = run_turn(&spec, None).await.expect("ok");
|
let lines = run_turn(&spec, None, None).await.expect("ok");
|
||||||
assert_eq!(lines, vec!["pong"]);
|
assert_eq!(lines, vec!["pong"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -872,8 +884,9 @@ mod tests {
|
|||||||
cwd: "/".to_owned(),
|
cwd: "/".to_owned(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
stdin: None,
|
stdin: None,
|
||||||
|
sandbox: None,
|
||||||
};
|
};
|
||||||
let err = run_turn(&spec, Some(Duration::from_millis(50)))
|
let err = run_turn(&spec, Some(Duration::from_millis(50)), None)
|
||||||
.await
|
.await
|
||||||
.expect_err("doit expirer");
|
.expect_err("doit expirer");
|
||||||
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
|
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
|
||||||
@ -894,6 +907,8 @@ mod tests {
|
|||||||
cmd.clone(),
|
cmd.clone(),
|
||||||
"/",
|
"/",
|
||||||
Some("resume-id".to_owned()),
|
Some("resume-id".to_owned()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
// conversation_id amorcé avant tout tour.
|
// conversation_id amorcé avant tout tour.
|
||||||
assert_eq!(session.conversation_id().as_deref(), Some("resume-id"));
|
assert_eq!(session.conversation_id().as_deref(), Some("resume-id"));
|
||||||
@ -938,6 +953,8 @@ mod tests {
|
|||||||
cmd.clone(),
|
cmd.clone(),
|
||||||
"/",
|
"/",
|
||||||
Some("cx-id".to_owned()),
|
Some("cx-id".to_owned()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let _ = session.send("vas-y").await.expect("send ok");
|
let _ = session.send("vas-y").await.expect("send ok");
|
||||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||||
@ -960,7 +977,7 @@ mod tests {
|
|||||||
r#"{"type":"system","subtype":"init","session_id":"captured-1"}"#,
|
r#"{"type":"system","subtype":"init","session_id":"captured-1"}"#,
|
||||||
r#"{"type":"result","subtype":"success","result":"r","session_id":"captured-1"}"#,
|
r#"{"type":"result","subtype":"success","result":"r","session_id":"captured-1"}"#,
|
||||||
]);
|
]);
|
||||||
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None);
|
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
|
||||||
assert_eq!(session.conversation_id(), None);
|
assert_eq!(session.conversation_id(), None);
|
||||||
let _ = session.send("t1").await.expect("t1");
|
let _ = session.send("t1").await.expect("t1");
|
||||||
assert_eq!(session.conversation_id().as_deref(), Some("captured-1"));
|
assert_eq!(session.conversation_id().as_deref(), Some("captured-1"));
|
||||||
@ -999,6 +1016,7 @@ mod tests {
|
|||||||
&SessionPlan::Resume {
|
&SessionPlan::Resume {
|
||||||
conversation_id: "cx-resume".to_owned(),
|
conversation_id: "cx-resume".to_owned(),
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("start resume codex");
|
.expect("start resume codex");
|
||||||
@ -1021,6 +1039,7 @@ mod tests {
|
|||||||
&SessionPlan::Assign {
|
&SessionPlan::Assign {
|
||||||
conversation_id: "ignored-by-engine".to_owned(),
|
conversation_id: "ignored-by-engine".to_owned(),
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("start assign");
|
.expect("start assign");
|
||||||
@ -1045,7 +1064,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("profil valide");
|
.expect("profil valide");
|
||||||
match factory
|
match factory
|
||||||
.start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None)
|
.start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Err(AgentSessionError::Start(_)) => {}
|
Err(AgentSessionError::Start(_)) => {}
|
||||||
@ -1068,6 +1087,8 @@ mod tests {
|
|||||||
fake.command(),
|
fake.command(),
|
||||||
"/",
|
"/",
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
));
|
));
|
||||||
assert_agent_session_contract(session, "cx-0", "direct").await;
|
assert_agent_session_contract(session, "cx-0", "direct").await;
|
||||||
}
|
}
|
||||||
@ -1096,7 +1117,7 @@ mod tests {
|
|||||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"},{"type":"tool_use","name":"T"},{"type":"text","text":"b"}]},"session_id":"flow-1","parent_tool_use_id":null}"#,
|
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"},{"type":"tool_use","name":"T"},{"type":"text","text":"b"}]},"session_id":"flow-1","parent_tool_use_id":null}"#,
|
||||||
r#"{"type":"result","subtype":"success","is_error":false,"result":"final-ok","session_id":"flow-1","num_turns":1}"#,
|
r#"{"type":"result","subtype":"success","is_error":false,"result":"final-ok","session_id":"flow-1","num_turns":1}"#,
|
||||||
]);
|
]);
|
||||||
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
|
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||||
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
|
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
events,
|
events,
|
||||||
@ -1133,7 +1154,7 @@ mod tests {
|
|||||||
r#"{"type":"system","subtype":"init","session_id":"new-1"}"#,
|
r#"{"type":"system","subtype":"init","session_id":"new-1"}"#,
|
||||||
r#"{"type":"result","subtype":"success","result":"r","session_id":"new-1"}"#,
|
r#"{"type":"result","subtype":"success","result":"r","session_id":"new-1"}"#,
|
||||||
]);
|
]);
|
||||||
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None);
|
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
|
||||||
let _ = session.send("bonjour").await.expect("send ok");
|
let _ = session.send("bonjour").await.expect("send ok");
|
||||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||||
let args: Vec<&str> = recorded.lines().collect();
|
let args: Vec<&str> = recorded.lines().collect();
|
||||||
@ -1165,7 +1186,7 @@ mod tests {
|
|||||||
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
||||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
||||||
]);
|
]);
|
||||||
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None);
|
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
|
||||||
let _ = session.send("salut").await.expect("send ok");
|
let _ = session.send("salut").await.expect("send ok");
|
||||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||||
let args: Vec<&str> = recorded.lines().collect();
|
let args: Vec<&str> = recorded.lines().collect();
|
||||||
@ -1207,7 +1228,7 @@ mod tests {
|
|||||||
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
||||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
||||||
]);
|
]);
|
||||||
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None);
|
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
|
||||||
let _ = session.send("salut").await.expect("send ok");
|
let _ = session.send("salut").await.expect("send ok");
|
||||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||||
let args: Vec<&str> = recorded.lines().collect();
|
let args: Vec<&str> = recorded.lines().collect();
|
||||||
@ -1241,6 +1262,8 @@ mod tests {
|
|||||||
cmd.clone(),
|
cmd.clone(),
|
||||||
"/",
|
"/",
|
||||||
Some("cx-id".to_owned()),
|
Some("cx-id".to_owned()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let _ = session.send("vas-y").await.expect("send ok");
|
let _ = session.send("vas-y").await.expect("send ok");
|
||||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||||
|
|||||||
@ -28,12 +28,14 @@
|
|||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
use domain::ports::AgentSessionError;
|
use domain::ports::AgentSessionError;
|
||||||
|
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||||
|
|
||||||
/// Une invocation orientée lignes : binaire + arguments + cwd + env + prompt à
|
/// Une invocation orientée lignes : binaire + arguments + cwd + env + prompt à
|
||||||
/// pousser sur stdin (le cas échéant). **Paramétrable par la commande** : c'est ce
|
/// pousser sur stdin (le cas échéant). **Paramétrable par la commande** : c'est ce
|
||||||
@ -51,6 +53,11 @@ pub struct SpawnLine {
|
|||||||
/// Contenu à écrire sur stdin du process (`None` ⇒ stdin fermé immédiatement).
|
/// Contenu à écrire sur stdin du process (`None` ⇒ stdin fermé immédiatement).
|
||||||
/// Sert au mode `--input-format stream-json` / au passage du prompt.
|
/// Sert au mode `--input-format stream-json` / au passage du prompt.
|
||||||
pub stdin: Option<String>,
|
pub stdin: Option<String>,
|
||||||
|
/// Plan de sandbox OS à appliquer sur l'enfant (lot LP4-4). `None` ⇒ aucun
|
||||||
|
/// sandboxing : `run_turn` emprunte le drain async tokio **inchangé** (invariant
|
||||||
|
/// produit : rien posé ⇒ comportement natif). `Some` **et** un enforcer fourni à
|
||||||
|
/// [`run_turn`] ⇒ le plan est appliqué sur l'enfant via [`drain_sandboxed`].
|
||||||
|
pub sandbox: Option<SandboxPlan>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lance `spec`, pousse `spec.stdin` sur l'entrée standard, **draine toutes les
|
/// Lance `spec`, pousse `spec.stdin` sur l'entrée standard, **draine toutes les
|
||||||
@ -61,14 +68,33 @@ pub struct SpawnLine {
|
|||||||
/// [`AgentSessionError::Timeout`] est retourné (la machinerie ne suppose jamais que
|
/// [`AgentSessionError::Timeout`] est retourné (la machinerie ne suppose jamais que
|
||||||
/// l'appelant veut attendre indéfiniment). `None` ⇒ pas de borne.
|
/// l'appelant veut attendre indéfiniment). `None` ⇒ pas de borne.
|
||||||
///
|
///
|
||||||
|
/// Quand `spec.sandbox` porte un plan **et** qu'un `enforcer` est fourni (chemin
|
||||||
|
/// Linux uniquement, lot LP4-4), le tour passe par [`run_turn_sandboxed`] : l'enfant
|
||||||
|
/// est lancé sous le domaine Landlock. Sinon — et **partout** hors Linux — c'est le
|
||||||
|
/// drain async tokio historique, strictement inchangé (zéro régression).
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// - [`AgentSessionError::Start`] si le process ne démarre pas (binaire introuvable) ;
|
/// - [`AgentSessionError::Start`] si le process ne démarre pas (binaire introuvable,
|
||||||
|
/// ou enforcement de sandbox impossible : fail-closed, **aucun** enfant ne tourne) ;
|
||||||
/// - [`AgentSessionError::Io`] sur échec de lecture/écriture des pipes ;
|
/// - [`AgentSessionError::Io`] sur échec de lecture/écriture des pipes ;
|
||||||
/// - [`AgentSessionError::Timeout`] si `timeout` expire.
|
/// - [`AgentSessionError::Timeout`] si `timeout` expire.
|
||||||
pub async fn run_turn(
|
pub async fn run_turn(
|
||||||
spec: &SpawnLine,
|
spec: &SpawnLine,
|
||||||
timeout: Option<Duration>,
|
timeout: Option<Duration>,
|
||||||
|
enforcer: Option<&Arc<dyn SandboxEnforcer>>,
|
||||||
) -> Result<Vec<String>, AgentSessionError> {
|
) -> Result<Vec<String>, AgentSessionError> {
|
||||||
|
// Chemin SANDBOXÉ (Linux + plan posé + enforcer câblé) : transpose la technique
|
||||||
|
// du PTY (`spawn_command_sandboxed`) — enforce sur un thread jetable AVANT le fork,
|
||||||
|
// l'enfant hérite le domaine via fork+exec.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let (Some(plan), Some(enforcer)) = (spec.sandbox.as_ref(), enforcer) {
|
||||||
|
return run_turn_sandboxed(spec, plan.clone(), Arc::clone(enforcer), timeout).await;
|
||||||
|
}
|
||||||
|
// Hors Linux : aucun sandboxing OS ⇒ on ignore l'enforcer (Noop de toute façon) et
|
||||||
|
// on garde le drain async historique. `let _` évite l'avertissement « unused ».
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let _ = enforcer;
|
||||||
|
|
||||||
match timeout {
|
match timeout {
|
||||||
Some(dur) => match tokio::time::timeout(dur, drain(spec)).await {
|
Some(dur) => match tokio::time::timeout(dur, drain(spec)).await {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
@ -78,6 +104,182 @@ pub async fn run_turn(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Variante **sandboxée** du tour (lot LP4-4, Linux seulement). Le drain bloquant
|
||||||
|
/// `std::process` est exécuté sur un **thread jetable** : on y restreint le thread
|
||||||
|
/// (`enforcer.enforce(plan)`) AVANT le `spawn`, puis on lance l'enfant. La technique
|
||||||
|
/// reproduit celle du PTY ([`crate::pty`]) : `pre_exec(enforce)` est INTERDIT (Landlock
|
||||||
|
/// alloue ⇒ risque de deadlock `malloc` post-`fork` en process multithreadé), on
|
||||||
|
/// s'appuie donc sur l'**héritage du domaine Landlock**.
|
||||||
|
///
|
||||||
|
/// ## Pourquoi pas de `pre_exec` (divergence assumée du cadrage)
|
||||||
|
///
|
||||||
|
/// Le cadrage proposait un `pre_exec` **vide** pour forcer `std` sur le chemin
|
||||||
|
/// déterministe `fork`+`exec` (jamais `posix_spawn`). Or `pre_exec` est `unsafe`, et
|
||||||
|
/// cette crate est `#![forbid(unsafe_code)]` (invariant non contournable localement).
|
||||||
|
/// On s'en passe sans perte de garantie : `landlock_restrict_self` restreint le
|
||||||
|
/// **thread courant et toute sa descendance**, héritage assuré par le noyau à travers
|
||||||
|
/// `fork`/`clone`/`vfork` **et** préservé par `execve` — donc aussi via `posix_spawn`
|
||||||
|
/// (qui est `clone`+`execve` sous le capot), car l'enforcement vit au niveau des
|
||||||
|
/// *credentials* de la tâche, hors d'atteinte de l'espace utilisateur. Le PTY n'obtenait
|
||||||
|
/// le `fork`+`exec` que comme **effet de bord** du `pre_exec` interne de `portable-pty` ;
|
||||||
|
/// la garantie de sécurité, elle, ne repose que sur cet héritage. Le thread meurt avec
|
||||||
|
/// sa restriction, les autres threads d'IdeA ne sont jamais touchés.
|
||||||
|
///
|
||||||
|
/// `enforce` fail-closed : un `Err` ⇒ [`AgentSessionError::Start`] et **aucun** enfant.
|
||||||
|
///
|
||||||
|
/// ## Timeout sous sandbox
|
||||||
|
///
|
||||||
|
/// Le thread bloquant n'est pas annulable « de l'extérieur ». On le réconcilie avec
|
||||||
|
/// l'async par deux canaux oneshot : le thread renvoie un **killer**
|
||||||
|
/// (`Arc<Mutex<Child>>`) juste après le spawn, puis son résultat à la fin. On pose
|
||||||
|
/// `tokio::time::timeout` sur la réception du résultat ; à expiration on **tue
|
||||||
|
/// l'enfant** via le killer ⇒ EOF côté stdout ⇒ le thread sort de sa boucle de drain,
|
||||||
|
/// `wait()` (reap, pas de zombie) et se termine. On renvoie alors [`AgentSessionError::Timeout`].
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
async fn run_turn_sandboxed(
|
||||||
|
spec: &SpawnLine,
|
||||||
|
plan: SandboxPlan,
|
||||||
|
enforcer: Arc<dyn SandboxEnforcer>,
|
||||||
|
timeout: Option<Duration>,
|
||||||
|
) -> Result<Vec<String>, AgentSessionError> {
|
||||||
|
use std::sync::Mutex as StdMutex;
|
||||||
|
|
||||||
|
// Données possédées : rien n'emprunte le thread jetable.
|
||||||
|
let command = spec.command.clone();
|
||||||
|
let args = spec.args.clone();
|
||||||
|
let cwd = spec.cwd.clone();
|
||||||
|
let env = spec.env.clone();
|
||||||
|
let stdin = spec.stdin.clone();
|
||||||
|
|
||||||
|
let (killer_tx, killer_rx) =
|
||||||
|
tokio::sync::oneshot::channel::<Arc<StdMutex<std::process::Child>>>();
|
||||||
|
let (done_tx, done_rx) =
|
||||||
|
tokio::sync::oneshot::channel::<Result<Vec<String>, AgentSessionError>>();
|
||||||
|
|
||||||
|
// Thread JETABLE : sa restriction Landlock meurt avec lui.
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result =
|
||||||
|
drain_sandboxed(command, args, cwd, env, stdin, &enforcer, &plan, killer_tx);
|
||||||
|
// Le récepteur peut avoir abandonné (timeout) : on ignore l'erreur d'envoi.
|
||||||
|
let _ = done_tx.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
match timeout {
|
||||||
|
Some(dur) => match tokio::time::timeout(dur, done_rx).await {
|
||||||
|
// Le thread a fini dans les temps (succès ou erreur métier).
|
||||||
|
Ok(Ok(result)) => result,
|
||||||
|
// Sender lâché sans valeur (panique du thread) ⇒ I/O.
|
||||||
|
Ok(Err(_canceled)) => Err(AgentSessionError::Io(
|
||||||
|
"thread sandbox terminé sans résultat".to_owned(),
|
||||||
|
)),
|
||||||
|
// Expiration : tue l'enfant (⇒ EOF ⇒ le thread se termine et reap).
|
||||||
|
Err(_elapsed) => {
|
||||||
|
if let Ok(child) = killer_rx.await {
|
||||||
|
if let Ok(mut c) = child.lock() {
|
||||||
|
let _ = c.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(AgentSessionError::Timeout)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => match done_rx.await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_canceled) => Err(AgentSessionError::Io(
|
||||||
|
"thread sandbox terminé sans résultat".to_owned(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain **bloquant et sandboxé** exécuté sur le thread jetable (lot LP4-4, Linux).
|
||||||
|
///
|
||||||
|
/// 1. `enforce(plan)` restreint CE thread (fail-closed) — la descendance hérite le
|
||||||
|
/// domaine Landlock (cf. doc de [`run_turn_sandboxed`]) ;
|
||||||
|
/// 2. `std::process::Command::spawn` (aucun `pre_exec` : `unsafe` interdit ici) ;
|
||||||
|
/// 3. pousse le prompt sur stdin puis EOF ;
|
||||||
|
/// 4. sort `stdout` du child **avant** de le partager : le drain lit sans tenir le
|
||||||
|
/// `Mutex`, donc le killer (timeout) peut verrouiller et tuer à tout moment ;
|
||||||
|
/// 5. draine ligne-à-ligne jusqu'à EOF ;
|
||||||
|
/// 6. `wait()` (reap) — pas de zombie.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn drain_sandboxed(
|
||||||
|
command: String,
|
||||||
|
args: Vec<String>,
|
||||||
|
cwd: String,
|
||||||
|
env: Vec<(String, String)>,
|
||||||
|
stdin_content: Option<String>,
|
||||||
|
enforcer: &Arc<dyn SandboxEnforcer>,
|
||||||
|
plan: &SandboxPlan,
|
||||||
|
killer_tx: tokio::sync::oneshot::Sender<Arc<std::sync::Mutex<std::process::Child>>>,
|
||||||
|
) -> Result<Vec<String>, AgentSessionError> {
|
||||||
|
use std::io::{BufRead, BufReader as StdBufReader, Write as _};
|
||||||
|
use std::process::{Command as StdCommand, Stdio};
|
||||||
|
use std::sync::Mutex as StdMutex;
|
||||||
|
|
||||||
|
// 1. Restreint CE thread AVANT tout fork (fail-closed : Err ⇒ aucun child ne tourne).
|
||||||
|
enforcer
|
||||||
|
.enforce(plan)
|
||||||
|
.map_err(|e| AgentSessionError::Start(format!("sandbox enforcement failed: {e}")))?;
|
||||||
|
|
||||||
|
// 2. Commande std (≡ `drain` async, mais synchrone).
|
||||||
|
let mut cmd = StdCommand::new(&command);
|
||||||
|
cmd.args(&args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
if cwd != "/" && !cwd.is_empty() {
|
||||||
|
cmd.current_dir(&cwd);
|
||||||
|
}
|
||||||
|
for (key, value) in &env {
|
||||||
|
cmd.env(key, value);
|
||||||
|
}
|
||||||
|
// Pas de `pre_exec` : il serait `unsafe` (interdit dans cette crate). L'enfant
|
||||||
|
// hérite de toute façon le domaine Landlock posé sur ce thread (cf. doc de
|
||||||
|
// `run_turn_sandboxed`), que `std` emprunte `posix_spawn` ou `fork`+`exec`.
|
||||||
|
|
||||||
|
let mut child = cmd
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| AgentSessionError::Start(format!("{command}: {e}")))?;
|
||||||
|
|
||||||
|
// 3. Pousse le prompt sur stdin puis le ferme (EOF). Erreur d'I/O ⇒ `Io`.
|
||||||
|
if let Some(input) = &stdin_content {
|
||||||
|
let mut si = child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| AgentSessionError::Io("stdin pipe indisponible".to_owned()))?;
|
||||||
|
si.write_all(input.as_bytes())
|
||||||
|
.map_err(|e| AgentSessionError::Io(e.to_string()))?;
|
||||||
|
drop(si);
|
||||||
|
} else {
|
||||||
|
drop(child.stdin.take());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Sort stdout AVANT de partager le child (drain sans lock ⇒ killer libre).
|
||||||
|
let stdout = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| AgentSessionError::Io("stdout pipe indisponible".to_owned()))?;
|
||||||
|
let child = Arc::new(StdMutex::new(child));
|
||||||
|
// Donne au côté async de quoi tuer l'enfant en cas de timeout.
|
||||||
|
let _ = killer_tx.send(Arc::clone(&child));
|
||||||
|
|
||||||
|
// 5. Drain ligne-à-ligne jusqu'à EOF. Un kill côté async ferme stdout ⇒ EOF.
|
||||||
|
let mut collected = Vec::new();
|
||||||
|
for line in StdBufReader::new(stdout).lines() {
|
||||||
|
match line {
|
||||||
|
Ok(l) => collected.push(l),
|
||||||
|
Err(e) => return Err(AgentSessionError::Io(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Reap (pas de zombie). Lock tenu brièvement.
|
||||||
|
if let Ok(mut c) = child.lock() {
|
||||||
|
let _ = c.wait();
|
||||||
|
}
|
||||||
|
Ok(collected)
|
||||||
|
}
|
||||||
|
|
||||||
/// Cœur du drain : spawn → écriture stdin → lecture ligne-à-ligne → wait.
|
/// Cœur du drain : spawn → écriture stdin → lecture ligne-à-ligne → wait.
|
||||||
async fn drain(spec: &SpawnLine) -> Result<Vec<String>, AgentSessionError> {
|
async fn drain(spec: &SpawnLine) -> Result<Vec<String>, AgentSessionError> {
|
||||||
let mut cmd = Command::new(&spec.command);
|
let mut cmd = Command::new(&spec.command);
|
||||||
|
|||||||
479
crates/infrastructure/src/session/sandbox_e2e.rs
Normal file
479
crates/infrastructure/src/session/sandbox_e2e.rs
Normal file
@ -0,0 +1,479 @@
|
|||||||
|
//! **Tests bout-en-bout de l'enforcement Landlock sur le chemin STRUCTURÉ** (lot
|
||||||
|
//! LP4-4). Pair du module `sandbox_e2e_tests` ajouté dans [`crate::pty`] au lot
|
||||||
|
//! LP4-3 (chemin PTY brut) ; ici la cible est la machinerie des sessions structurées
|
||||||
|
//! (Claude/Codex en mode JSON) : `run_turn` → `run_turn_sandboxed` → `drain_sandboxed`
|
||||||
|
//! (thread jetable restreint par `enforce()` AVANT le spawn std, puis héritage du
|
||||||
|
//! domaine Landlock à travers `fork`/`exec`).
|
||||||
|
//!
|
||||||
|
//! **ZÉRO token** : aucun vrai `claude`/`codex` n'est lancé. On substitue soit `sh`
|
||||||
|
//! (qui émet une ligne JSONL puis tente des écritures FS), soit le [`FakeCli`]
|
||||||
|
//! scriptable existant. Tout est derrière `#[cfg(all(test, target_os = "linux"))]`
|
||||||
|
//! et — pour les assertions qui exigent un fencing réel — derrière le garde
|
||||||
|
//! [`landlock_is_enforced`] (skip propre sur un kernel sans Landlock, comme les
|
||||||
|
//! tests de l'adapter et du PTY).
|
||||||
|
//!
|
||||||
|
//! ## Couverture des 7 invariants (Architecte)
|
||||||
|
//!
|
||||||
|
//! 1. **Parité** — `pty_structured_run_turn_enforces_plan_end_to_end`
|
||||||
|
//! 2. **Companion négatif** — `structured_run_turn_without_plan_does_not_sandbox`
|
||||||
|
//! 3. **Fail-closed** — `structured_run_turn_fail_closed_no_child_on_enforce_err`
|
||||||
|
//! 4. **No-op par défaut** — `structured_run_turn_none_plan_is_native_path`
|
||||||
|
//! 5. **Confinement/irréversibilité** — `structured_two_turns_disjoint_grants_are_confined`
|
||||||
|
//! 6. **Timeout sous sandbox** — `structured_run_turn_timeout_under_sandbox`
|
||||||
|
//! 7. **Resume préservé** — `structured_sandboxed_turn_preserves_conversation_id`
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use domain::sandbox::{
|
||||||
|
PathAccess, PathGrant, SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus,
|
||||||
|
};
|
||||||
|
use domain::Posture;
|
||||||
|
|
||||||
|
use super::process::{run_turn, SpawnLine};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers (calqués sur sandbox/landlock.rs et pty::sandbox_e2e_tests)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Un répertoire temporaire unique pour un test (zéro dépendance tempfile).
|
||||||
|
fn fresh_dir(tag: &str) -> PathBuf {
|
||||||
|
let nanos = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let p = std::env::temp_dir().join(format!("idea-struct-sbx-{tag}-{nanos}"));
|
||||||
|
std::fs::create_dir_all(&p).unwrap();
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sonde si le kernel courant **applique réellement** Landlock, exactement comme le
|
||||||
|
/// chemin de lancement l'observerait : `enforce` d'un petit plan RW sur un **thread
|
||||||
|
/// jetable** (la restriction est irréversible ⇒ jamais sur le thread de test) et
|
||||||
|
/// renvoie `false` sur [`SandboxStatus::Unsupported`]. Permet le skip propre.
|
||||||
|
fn landlock_is_enforced() -> bool {
|
||||||
|
let probe = fresh_dir("probe");
|
||||||
|
let plan = SandboxPlan {
|
||||||
|
allowed: vec![PathGrant {
|
||||||
|
abs_root: probe.to_string_lossy().into_owned(),
|
||||||
|
access: PathAccess::RW,
|
||||||
|
}],
|
||||||
|
default_posture: Posture::Ask,
|
||||||
|
};
|
||||||
|
let status = std::thread::spawn(move || crate::sandbox::LandlockSandbox::new().enforce(&plan))
|
||||||
|
.join()
|
||||||
|
.unwrap()
|
||||||
|
.expect("enforce ne doit pas échouer sous une posture Ask");
|
||||||
|
let _ = std::fs::remove_dir_all(&probe);
|
||||||
|
!matches!(status, SandboxStatus::Unsupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plan RW sur un seul répertoire, posture `Ask` (jamais fail-closed).
|
||||||
|
fn rw_plan(root: &Path) -> SandboxPlan {
|
||||||
|
SandboxPlan {
|
||||||
|
allowed: vec![PathGrant {
|
||||||
|
abs_root: root.to_string_lossy().into_owned(),
|
||||||
|
access: PathAccess::RW,
|
||||||
|
}],
|
||||||
|
default_posture: Posture::Ask,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construit un [`SpawnLine`] sur `sh -c` qui **émet une ligne JSONL** sur stdout
|
||||||
|
/// (preuve que le drain/parsing du chemin structuré survit au sandbox) puis tente
|
||||||
|
/// d'écrire HORS grant **puis** DANS le grant. L'écriture hors-grant est tentée en
|
||||||
|
/// premier : si le marqueur in-grant apparaît, la tentative hors-grant a déjà eu lieu.
|
||||||
|
/// `json_line` ne doit contenir que des guillemets doubles (pas de quote simple).
|
||||||
|
fn writing_spawn_line(
|
||||||
|
json_line: &str,
|
||||||
|
denied_marker: &Path,
|
||||||
|
allowed_marker: &Path,
|
||||||
|
plan: Option<SandboxPlan>,
|
||||||
|
) -> SpawnLine {
|
||||||
|
let script = format!(
|
||||||
|
"printf '%s\\n' '{json}'; echo outside > '{denied}'; echo inside > '{allowed}'",
|
||||||
|
json = json_line,
|
||||||
|
denied = denied_marker.display(),
|
||||||
|
allowed = allowed_marker.display(),
|
||||||
|
);
|
||||||
|
SpawnLine {
|
||||||
|
command: "sh".to_owned(),
|
||||||
|
args: vec!["-c".to_owned(), script],
|
||||||
|
cwd: "/".to_owned(),
|
||||||
|
env: Vec::new(),
|
||||||
|
stdin: None,
|
||||||
|
sandbox: plan,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Une ligne JSONL `result` réaliste (format Claude vérifié) — sans quote simple.
|
||||||
|
const RESULT_LINE: &str =
|
||||||
|
r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s-1"}"#;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// INVARIANT 1 — PARITÉ (test pivot)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// **Pivot.** Le chemin structuré sandboxé (`run_turn` + plan + enforcer Landlock)
|
||||||
|
/// laisse l'écriture DANS le grant réussir, bloque (noyau) l'écriture HORS grant, et
|
||||||
|
/// **draine quand même** la ligne JSONL émise sur stdout (le parsing n'est pas cassé
|
||||||
|
/// par la restriction FS). Strictement équivalent au pivot PTY du lot LP4-3, mais via
|
||||||
|
/// la machinerie `process::run_turn` (chemin Claude/Codex JSON).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pty_structured_run_turn_enforces_plan_end_to_end() {
|
||||||
|
if !landlock_is_enforced() {
|
||||||
|
eprintln!("skip pty_structured_run_turn_enforces_plan_end_to_end: Landlock indisponible");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let allowed = fresh_dir("p1-allowed");
|
||||||
|
let denied = fresh_dir("p1-denied");
|
||||||
|
let allowed_marker = allowed.join("in.txt");
|
||||||
|
let denied_marker = denied.join("out.txt");
|
||||||
|
|
||||||
|
let spec = writing_spawn_line(
|
||||||
|
RESULT_LINE,
|
||||||
|
&denied_marker,
|
||||||
|
&allowed_marker,
|
||||||
|
Some(rw_plan(&allowed)),
|
||||||
|
);
|
||||||
|
let enforcer = crate::sandbox::default_enforcer();
|
||||||
|
|
||||||
|
let lines = run_turn(&spec, None, Some(&enforcer))
|
||||||
|
.await
|
||||||
|
.expect("run_turn sandboxé réussit sous posture Ask");
|
||||||
|
|
||||||
|
// Le drain a bien remonté la ligne JSONL (parsing intact sous sandbox).
|
||||||
|
assert_eq!(
|
||||||
|
lines,
|
||||||
|
vec![RESULT_LINE.to_owned()],
|
||||||
|
"la ligne JSONL doit être drainée malgré la restriction FS"
|
||||||
|
);
|
||||||
|
// L'écriture in-grant a réussi…
|
||||||
|
assert!(
|
||||||
|
allowed_marker.exists(),
|
||||||
|
"écriture in-grant doit réussir: {allowed_marker:?} absent — le grant a été mal fencé"
|
||||||
|
);
|
||||||
|
// …et l'écriture hors-grant a été bloquée par le noyau.
|
||||||
|
assert!(
|
||||||
|
!denied_marker.exists(),
|
||||||
|
"BRÈCHE SANDBOX: écriture hors-grant réussie ({denied_marker:?}) — plan NON enforce sur le chemin structuré"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&allowed);
|
||||||
|
let _ = std::fs::remove_dir_all(&denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// INVARIANT 2 — COMPANION NÉGATIF
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Même machinerie + enforcer câblé, mais `sandbox == None` ⇒ l'écriture hors-grant
|
||||||
|
/// RÉUSSIT. Prouve que le blocage de l'invariant 1 vient du **plan enforce**, pas
|
||||||
|
/// d'une restriction ambiante du chemin structuré. (Contrôle anti faux-positif.)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_run_turn_without_plan_does_not_sandbox() {
|
||||||
|
let allowed = fresh_dir("p2-allowed");
|
||||||
|
let denied = fresh_dir("p2-denied");
|
||||||
|
let allowed_marker = allowed.join("in.txt");
|
||||||
|
let denied_marker = denied.join("out.txt");
|
||||||
|
|
||||||
|
// plan = None ⇒ chemin natif, l'enforcer est ignoré même s'il est fourni.
|
||||||
|
let spec = writing_spawn_line(RESULT_LINE, &denied_marker, &allowed_marker, None);
|
||||||
|
let enforcer = crate::sandbox::default_enforcer();
|
||||||
|
|
||||||
|
let lines = run_turn(&spec, None, Some(&enforcer))
|
||||||
|
.await
|
||||||
|
.expect("run_turn natif réussit");
|
||||||
|
|
||||||
|
assert_eq!(lines, vec![RESULT_LINE.to_owned()]);
|
||||||
|
assert!(allowed_marker.exists(), "écriture in-grant réussit (sans plan)");
|
||||||
|
assert!(
|
||||||
|
denied_marker.exists(),
|
||||||
|
"sans plan, l'écriture hors-grant DOIT réussir: {denied_marker:?} absent — restriction ambiante anormale"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&allowed);
|
||||||
|
let _ = std::fs::remove_dir_all(&denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// INVARIANT 3 — FAIL-CLOSED
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Enforcer double qui **échoue toujours** — simule fidèlement le cas réel
|
||||||
|
/// « posture Deny sur kernel sans Landlock » (où `enforce` renvoie
|
||||||
|
/// [`SandboxError::KernelTooOld`]), de façon **déterministe** et indépendante du
|
||||||
|
/// kernel de CI (impossible de forcer `NotEnforced` sur une machine Landlock-capable).
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct AlwaysFailEnforcer;
|
||||||
|
|
||||||
|
impl SandboxEnforcer for AlwaysFailEnforcer {
|
||||||
|
fn kind(&self) -> SandboxKind {
|
||||||
|
SandboxKind::Landlock
|
||||||
|
}
|
||||||
|
fn enforce(&self, _plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError> {
|
||||||
|
Err(SandboxError::KernelTooOld(
|
||||||
|
"simulé: Landlock indisponible + posture Deny ⇒ fail-closed".to_owned(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Fail-closed.** Quand `enforce` renvoie `Err`, `run_turn` (chemin sandboxé) doit
|
||||||
|
/// remonter [`AgentSessionError::Start`] et **aucun enfant ne tourne** : le marqueur
|
||||||
|
/// que le child aurait écrit reste absent. C'est le câblage critique de sécurité du
|
||||||
|
/// chemin structuré (équivalent du fail-closed PTY).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_run_turn_fail_closed_no_child_on_enforce_err() {
|
||||||
|
use domain::ports::AgentSessionError;
|
||||||
|
|
||||||
|
let dir = fresh_dir("p3");
|
||||||
|
let marker = dir.join("should-not-exist.txt");
|
||||||
|
|
||||||
|
// Plan posture Deny (cohérent avec le scénario simulé) + enforcer qui échoue.
|
||||||
|
let plan = SandboxPlan {
|
||||||
|
allowed: vec![PathGrant {
|
||||||
|
abs_root: dir.to_string_lossy().into_owned(),
|
||||||
|
access: PathAccess::RW,
|
||||||
|
}],
|
||||||
|
default_posture: Posture::Deny,
|
||||||
|
};
|
||||||
|
// Le child écrirait CE marqueur s'il était lancé : il ne doit jamais l'être.
|
||||||
|
let script = format!("echo ran > '{}'", marker.display());
|
||||||
|
let spec = SpawnLine {
|
||||||
|
command: "sh".to_owned(),
|
||||||
|
args: vec!["-c".to_owned(), script],
|
||||||
|
cwd: "/".to_owned(),
|
||||||
|
env: Vec::new(),
|
||||||
|
stdin: None,
|
||||||
|
sandbox: Some(plan),
|
||||||
|
};
|
||||||
|
let enforcer: Arc<dyn SandboxEnforcer> = Arc::new(AlwaysFailEnforcer);
|
||||||
|
|
||||||
|
let err = run_turn(&spec, None, Some(&enforcer))
|
||||||
|
.await
|
||||||
|
.expect_err("enforce Err ⇒ run_turn doit échouer (fail-closed)");
|
||||||
|
assert!(
|
||||||
|
matches!(err, AgentSessionError::Start(_)),
|
||||||
|
"fail-closed doit remonter Start, vu: {err:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!marker.exists(),
|
||||||
|
"FAIL-CLOSED VIOLÉ: un enfant a tourné ({marker:?} existe) malgré l'échec d'enforce"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// INVARIANT 4 — NO-OP PAR DÉFAUT
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// **No-op par défaut.** `plan == None` (effective_permissions None) ⇒ le chemin async
|
||||||
|
/// tokio historique est emprunté, comportement natif inchangé : le drain remonte les
|
||||||
|
/// lignes et **aucune** restriction n'est posée (une écriture arbitraire réussit),
|
||||||
|
/// y compris quand un enforcer est pourtant câblé sur la fabrique. La non-régression
|
||||||
|
/// du gros de la suite structurée (conformance/D0/D3) confirme l'absence d'effet de bord.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_run_turn_none_plan_is_native_path() {
|
||||||
|
let dir = fresh_dir("p4");
|
||||||
|
let marker = dir.join("native.txt");
|
||||||
|
let script = format!("printf '%s\\n' '{RESULT_LINE}'; echo ok > '{}'", marker.display());
|
||||||
|
let spec = SpawnLine {
|
||||||
|
command: "sh".to_owned(),
|
||||||
|
args: vec!["-c".to_owned(), script],
|
||||||
|
cwd: "/".to_owned(),
|
||||||
|
env: Vec::new(),
|
||||||
|
stdin: None,
|
||||||
|
sandbox: None, // ⇐ rien posé ⇒ chemin natif
|
||||||
|
};
|
||||||
|
let enforcer = crate::sandbox::default_enforcer();
|
||||||
|
|
||||||
|
// Enforcer fourni MAIS plan None ⇒ la branche sandboxée n'est pas prise.
|
||||||
|
let lines = run_turn(&spec, None, Some(&enforcer))
|
||||||
|
.await
|
||||||
|
.expect("chemin natif réussit");
|
||||||
|
assert_eq!(lines, vec![RESULT_LINE.to_owned()]);
|
||||||
|
assert!(
|
||||||
|
marker.exists(),
|
||||||
|
"sans plan, aucune restriction: l'écriture native doit réussir"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// INVARIANT 5 — CONFINEMENT / IRRÉVERSIBILITÉ
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// **Confinement.** Deux tours successifs (instances `run_turn` distinctes) avec des
|
||||||
|
/// grants **disjoints** : chacun ne voit que son propre périmètre. Le thread jetable
|
||||||
|
/// du 1er tour meurt avec sa restriction ⇒ le 2e tour n'en hérite pas (et inversement).
|
||||||
|
/// Prouve que l'enforcement ne « bave » pas d'un tour à l'autre ni sur IdeA.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_two_turns_disjoint_grants_are_confined() {
|
||||||
|
if !landlock_is_enforced() {
|
||||||
|
eprintln!("skip structured_two_turns_disjoint_grants_are_confined: Landlock indisponible");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dir_a = fresh_dir("p5-a");
|
||||||
|
let dir_b = fresh_dir("p5-b");
|
||||||
|
let enforcer = crate::sandbox::default_enforcer();
|
||||||
|
|
||||||
|
// --- Tour A : grant = dir_a. Écrit dans A (ok) puis B (bloqué). ---
|
||||||
|
let a_in = dir_a.join("in.txt");
|
||||||
|
let a_into_b = dir_b.join("from-a.txt");
|
||||||
|
let spec_a = writing_spawn_line(RESULT_LINE, &a_into_b, &a_in, Some(rw_plan(&dir_a)));
|
||||||
|
run_turn(&spec_a, None, Some(&enforcer))
|
||||||
|
.await
|
||||||
|
.expect("tour A ok");
|
||||||
|
assert!(a_in.exists(), "tour A: écriture dans son grant (A) doit réussir");
|
||||||
|
assert!(
|
||||||
|
!a_into_b.exists(),
|
||||||
|
"tour A: écriture dans B (hors grant A) doit être bloquée"
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Tour B : grant = dir_b. Écrit dans B (ok) puis A (bloqué). ---
|
||||||
|
// Si la restriction du tour A avait bavé, l'écriture dans B échouerait ici.
|
||||||
|
let b_in = dir_b.join("in.txt");
|
||||||
|
let b_into_a = dir_a.join("from-b.txt");
|
||||||
|
let spec_b = writing_spawn_line(RESULT_LINE, &b_into_a, &b_in, Some(rw_plan(&dir_b)));
|
||||||
|
run_turn(&spec_b, None, Some(&enforcer))
|
||||||
|
.await
|
||||||
|
.expect("tour B ok");
|
||||||
|
assert!(
|
||||||
|
b_in.exists(),
|
||||||
|
"tour B: écriture dans son grant (B) doit réussir — la restriction du tour A n'a pas bavé"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!b_into_a.exists(),
|
||||||
|
"tour B: écriture dans A (hors grant B) doit être bloquée"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir_a);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir_b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// INVARIANT 6 — TIMEOUT SOUS SANDBOX
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// **Timeout sous sandbox.** Un enfant qui ne ferme jamais stdout (`sleep`) lancé via
|
||||||
|
/// le chemin sandboxé : `run_turn(timeout)` doit **tuer** l'enfant et renvoyer
|
||||||
|
/// [`AgentSessionError::Timeout`], rapidement (≪ durée du sleep) — preuve que le killer
|
||||||
|
/// oneshot + `tokio::time::timeout` fonctionnent et qu'aucun thread ne reste bloqué.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_run_turn_timeout_under_sandbox() {
|
||||||
|
use domain::ports::AgentSessionError;
|
||||||
|
|
||||||
|
let dir = fresh_dir("p6");
|
||||||
|
// `sleep 30` garde stdout ouvert ⇒ le drain bloquerait sans le killer du timeout.
|
||||||
|
let spec = SpawnLine {
|
||||||
|
command: "sleep".to_owned(),
|
||||||
|
args: vec!["30".to_owned()],
|
||||||
|
cwd: "/".to_owned(),
|
||||||
|
env: Vec::new(),
|
||||||
|
stdin: None,
|
||||||
|
sandbox: Some(rw_plan(&dir)), // ⇒ branche sandboxée (posture Ask ⇒ enforce Ok)
|
||||||
|
};
|
||||||
|
let enforcer = crate::sandbox::default_enforcer();
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let err = run_turn(&spec, Some(Duration::from_millis(250)), Some(&enforcer))
|
||||||
|
.await
|
||||||
|
.expect_err("doit expirer");
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(err, AgentSessionError::Timeout),
|
||||||
|
"le tour sandboxé doit expirer en Timeout, vu: {err:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
elapsed < Duration::from_secs(10),
|
||||||
|
"le timeout doit tuer l'enfant promptement (vu {elapsed:?}) — pas d'attente des 30s"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// INVARIANT 7 — RESUME PRÉSERVÉ (via la fabrique réelle + adapter Claude)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// **Resume préservé.** Après un tour réellement sandboxé, l'`session_id`/conversation_id
|
||||||
|
/// est toujours capté correctement : la restriction FS ne casse pas le parsing du flux.
|
||||||
|
/// Passe par la **fabrique réelle** `StructuredSessionFactory::with_sandbox_enforcer`,
|
||||||
|
/// un profil Claude branché sur un [`FakeCli`] (init + result), et un plan transmis à
|
||||||
|
/// `start(.., Some(&plan))`. Le fake n'écrit aucun fichier (il imprime sur un pipe, hors
|
||||||
|
/// périmètre Landlock-FS), donc un plan write-only suffit : on prouve que la capture
|
||||||
|
/// d'id survit au domaine actif.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_sandboxed_turn_preserves_conversation_id() {
|
||||||
|
use domain::ids::ProfileId;
|
||||||
|
use domain::ports::{AgentSessionFactory, PreparedContext, ReplyEvent, SessionPlan};
|
||||||
|
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
||||||
|
use domain::project::ProjectPath;
|
||||||
|
use domain::MarkdownDoc;
|
||||||
|
|
||||||
|
use super::conformance::FakeCli;
|
||||||
|
use super::factory::StructuredSessionFactory;
|
||||||
|
|
||||||
|
if !landlock_is_enforced() {
|
||||||
|
eprintln!("skip structured_sandboxed_turn_preserves_conversation_id: Landlock indisponible");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let run_dir = fresh_dir("p7");
|
||||||
|
let fake = FakeCli::printing(&[
|
||||||
|
r#"{"type":"system","subtype":"init","session_id":"conv-sbx-1","cwd":"/tmp","tools":[]}"#,
|
||||||
|
r#"{"type":"result","subtype":"success","is_error":false,"result":"réponse","session_id":"conv-sbx-1","num_turns":1}"#,
|
||||||
|
]);
|
||||||
|
|
||||||
|
let profile = AgentProfile::new(
|
||||||
|
ProfileId::new_random(),
|
||||||
|
"Claude sandboxé",
|
||||||
|
&fake.command(),
|
||||||
|
Vec::new(),
|
||||||
|
ContextInjection::convention_file("CLAUDE.md").expect("convention file valide"),
|
||||||
|
None,
|
||||||
|
"{agentRunDir}",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("profil valide")
|
||||||
|
.with_structured_adapter(StructuredAdapter::Claude);
|
||||||
|
|
||||||
|
let factory =
|
||||||
|
StructuredSessionFactory::new().with_sandbox_enforcer(crate::sandbox::default_enforcer());
|
||||||
|
|
||||||
|
let ctx = PreparedContext {
|
||||||
|
content: MarkdownDoc::new("# ctx"),
|
||||||
|
relative_path: "CLAUDE.md".to_owned(),
|
||||||
|
};
|
||||||
|
let cwd = ProjectPath::new(run_dir.to_string_lossy().into_owned()).expect("cwd absolu");
|
||||||
|
let plan = rw_plan(&run_dir); // plan write-only ⇒ reads/exec du fake non gênés
|
||||||
|
|
||||||
|
let session = factory
|
||||||
|
.start(&profile, &ctx, &cwd, &SessionPlan::None, Some(&plan))
|
||||||
|
.await
|
||||||
|
.expect("start sandboxé ok");
|
||||||
|
|
||||||
|
// Avant tout tour : aucun id moteur.
|
||||||
|
assert_eq!(session.conversation_id(), None);
|
||||||
|
|
||||||
|
// Un tour sandboxé : le flux doit porter un Final ET capter l'id.
|
||||||
|
let events: Vec<ReplyEvent> = session.send("salut").await.expect("send ok").collect();
|
||||||
|
let finals = events
|
||||||
|
.iter()
|
||||||
|
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
||||||
|
.count();
|
||||||
|
assert_eq!(finals, 1, "un Final attendu malgré le sandbox, vu: {events:?}");
|
||||||
|
|
||||||
|
// LE POINT : l'id de conversation a bien été capté sous enforcement actif.
|
||||||
|
assert_eq!(
|
||||||
|
session.conversation_id().as_deref(),
|
||||||
|
Some("conv-sbx-1"),
|
||||||
|
"la restriction FS ne doit pas casser la capture du conversation_id (resume)"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&run_dir);
|
||||||
|
}
|
||||||
@ -206,9 +206,9 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
{/* ── Create form ── */}
|
{/* ── Create form ── */}
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => void handleCreate(e)}
|
onSubmit={(e) => void handleCreate(e)}
|
||||||
className="flex flex-wrap items-end gap-2 border-b border-border p-4"
|
className="flex flex-col gap-3 border-b border-border p-4"
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="agent-name-input"
|
htmlFor="agent-name-input"
|
||||||
className="text-xs font-medium text-muted"
|
className="text-xs font-medium text-muted"
|
||||||
@ -221,12 +221,12 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
placeholder="My agent"
|
placeholder="My agent"
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
className="min-w-32"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Template selector */}
|
{/* Template selector */}
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="agent-template-select"
|
htmlFor="agent-template-select"
|
||||||
className="text-xs font-medium text-muted"
|
className="text-xs font-medium text-muted"
|
||||||
@ -256,7 +256,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
|
|
||||||
{/* Profile selector — hidden when a template is chosen */}
|
{/* Profile selector — hidden when a template is chosen */}
|
||||||
{!hasTemplate && (
|
{!hasTemplate && (
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="agent-profile-select"
|
htmlFor="agent-profile-select"
|
||||||
className="text-xs font-medium text-muted"
|
className="text-xs font-medium text-muted"
|
||||||
@ -304,6 +304,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
aria-label="create agent"
|
aria-label="create agent"
|
||||||
disabled={!canCreate}
|
disabled={!canCreate}
|
||||||
loading={vm.busy}
|
loading={vm.busy}
|
||||||
|
className="self-end"
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</Button>
|
</Button>
|
||||||
@ -330,17 +331,17 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
<li
|
<li
|
||||||
key={a.id}
|
key={a.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0",
|
"flex flex-col gap-2 py-3 first:pt-0 last:pb-0",
|
||||||
isSelected && "rounded-md bg-raised px-2",
|
isSelected && "rounded-md bg-raised px-2",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void vm.selectAgent(a.id)}
|
onClick={() => void vm.selectAgent(a.id)}
|
||||||
className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-left"
|
className="flex min-w-0 flex-col items-start gap-0.5 text-left"
|
||||||
aria-pressed={isSelected}
|
aria-pressed={isSelected}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
|
||||||
<span className="font-medium text-content">{a.name}</span>
|
<span className="font-medium text-content">{a.name}</span>
|
||||||
{agentDrift && (
|
{agentDrift && (
|
||||||
<span
|
<span
|
||||||
@ -366,13 +367,13 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted">{profileName}</span>
|
<span className="text-xs text-muted">{profileName}</span>
|
||||||
{live && (
|
{live && (
|
||||||
<span className="text-xs text-primary">
|
<span className="block w-full min-w-0 truncate text-xs text-primary">
|
||||||
running in IdeA · {live.sessionId ?? live.nodeId}
|
running in IdeA · {live.sessionId ?? live.nodeId}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{/* Profile hot-swap selector (Chantier A). Changing the
|
{/* Profile hot-swap selector (Chantier A). Changing the
|
||||||
engine abandons the conversation history → confirmation. */}
|
engine abandons the conversation history → confirmation. */}
|
||||||
{vm.profiles.length > 0 && (
|
{vm.profiles.length > 0 && (
|
||||||
|
|||||||
@ -147,30 +147,35 @@ export function ProjectsView() {
|
|||||||
sidebarCollapsed && "hidden",
|
sidebarCollapsed && "hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Sidebar tab strip (no role="tablist" to avoid collision with project tabs) */}
|
{/* Sidebar tab strip (no role="tablist" to avoid collision with project tabs).
|
||||||
<div className="flex shrink-0 items-stretch border-b border-border">
|
The tabs wrap onto multiple rows when they exceed the sidebar width
|
||||||
{SIDEBAR_TABS.map((t) => (
|
so every label stays visible without scrolling; the collapse button
|
||||||
<button
|
stays pinned to the top-right. */}
|
||||||
key={t.id}
|
<div className="flex shrink-0 items-start border-b border-border">
|
||||||
type="button"
|
<div className="flex min-w-0 flex-1 flex-wrap items-stretch">
|
||||||
aria-selected={sidebarTab === t.id}
|
{SIDEBAR_TABS.map((t) => (
|
||||||
onClick={() => setSidebarTab(t.id)}
|
<button
|
||||||
className={cn(
|
key={t.id}
|
||||||
"flex-1 px-2 py-2 text-xs font-medium transition-colors",
|
type="button"
|
||||||
sidebarTab === t.id
|
aria-selected={sidebarTab === t.id}
|
||||||
? "border-b-2 border-primary text-content"
|
onClick={() => setSidebarTab(t.id)}
|
||||||
: "text-muted hover:text-content",
|
className={cn(
|
||||||
)}
|
"shrink-0 whitespace-nowrap px-3 py-2 text-xs font-medium transition-colors",
|
||||||
>
|
sidebarTab === t.id
|
||||||
{t.label}
|
? "border-b-2 border-primary text-content"
|
||||||
</button>
|
: "text-muted hover:text-content",
|
||||||
))}
|
)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="collapse sidebar"
|
aria-label="collapse sidebar"
|
||||||
title="Collapse sidebar"
|
title="Collapse sidebar"
|
||||||
onClick={() => setSidebarCollapsed(true)}
|
onClick={() => setSidebarCollapsed(true)}
|
||||||
className="shrink-0 px-2 text-muted hover:text-content"
|
className="shrink-0 self-stretch border-l border-border px-2 text-muted hover:text-content"
|
||||||
>
|
>
|
||||||
«
|
«
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user