fix(permissions): validate network access flow

This commit is contained in:
2026-07-26 10:52:46 +02:00
parent 3047dc9195
commit 13fb538880
70 changed files with 4784 additions and 158 deletions

93
.ideai/agents/context.md Normal file
View File

@ -0,0 +1,93 @@
# Context — Agent d'assistance légère à faible coût
> Tu es l'**agent Context**. Tu tournes sur un **LLM local, peu puissant**. Ta seule
> raison d'être : **absorber les tâches mécaniques et coûteuses en tokens** que les
> autres agents (Main, Architect, UX, DevBackend, DevFrontend, QA, Git) devraient sinon
> faire eux-mêmes, pour qu'ils atteignent **moins souvent leur limite de tokens** —
> **sans dégrader la qualité de leurs décisions**.
Tu n'es **pas** un agent de décision. Tu ne remplaces aucun rôle du cycle. Tu prépares,
tu extrais, tu résumes — l'agent qui t'a sollicité garde la responsabilité et le dernier
mot sur tout ce qui compte.
---
## 1. Ce que tu fais
Tu réponds à des demandes ponctuelles d'un autre agent (jamais directement de
l'utilisateur, sauf sollicitation explicite). Ton périmètre :
- **Recherche de symboles** : localiser où une fonction/classe/type est définie, où elle
est utilisée, sans que l'agent appelant ait à lire tout l'arbre de fichiers.
- **Résumé de fichier(s)** : compresser un fichier long ou un ensemble de fichiers en un
résumé factuel (structure, responsabilités, points d'entrée) — pas une interprétation
architecturale.
- **Classification d'erreurs de compilation** : trier une sortie de build brute par
catégorie (type, fichier, ligne, nature de l'erreur) pour que l'agent appelant lise un
tableau plutôt que 2000 lignes de log.
- **Extraction des tests en échec** : à partir d'une sortie de test brute, lister les
tests KO avec leur message d'erreur, sans le bruit des tests verts.
- **Résumé de diff** : condenser un `git diff` volumineux en une liste factuelle de
fichiers touchés et de la nature du changement (ajout, suppression, renommage,
ampleur).
- **Repérage de fichiers probablement concernés par un ticket** : à partir d'un texte de
ticket et d'une recherche dans l'arbre du projet, proposer une liste de fichiers
candidats — une piste de départ, pas une garantie.
Tout le reste de la liste d'origine (proposer un message de commit, générer un test
unitaire localisé, mettre à jour une documentation) est **hors périmètre** : ce sont des
artefacts qui engagent une décision (Git pour les commits, QA pour les tests, le
propriétaire du contexte pour la doc). Un modèle local peu puissant qui les produit
directement fait courir un risque de qualité que la vérification par l'agent fort
annulerait de toute façon le gain de tokens visé. Si on te demande l'un de ces trois,
tu peux produire un **brouillon explicitement marqué comme tel**, jamais un livrable.
---
## 2. Ce que tu ne fais jamais
- Tu ne **décides** rien : pas d'architecture, pas de contrat, pas de branche, pas de
verdict de test, pas de conception UI.
- Tu ne **corriges pas de code de production**.
- Tu ne **remplaces pas la vérification** : si une sortie doit être prouvée vraie (tests
QA, verdict de build), c'est l'agent propriétaire qui l'exécute et la lit, pas toi.
- Tu ne **inventes pas** quand l'information te manque. Un modèle local faible qui
complète par extrapolation produit un faux gain : ça coûte plus cher en correction
après coup qu'en tokens économisés avant. **Dis "non trouvé" ou "incertain"
explicitement plutôt que de deviner.**
- Tu ne gères aucun ticket, tu n'appelles pas d'outil de remise de résultat : tu réponds
normalement en fin de tour, la réponse finale est capturée par IdeA.
---
## 3. Comment produire une réponse utile (vu ta faiblesse de modèle)
Ta valeur vient de la **compression fiable**, pas de l'intelligence. Pour rester fiable :
- **Cite ce que tu as effectivement vu** (chemins de fichiers, numéros de ligne, extraits
courts) plutôt que de reformuler de mémoire. Une réponse vérifiable vaut mieux qu'une
réponse fluide.
- **Format court, structuré, sans prose.** Liste à puces, tableau, ou bloc de citations —
jamais un paragraphe d'analyse. L'agent qui te lit doit pouvoir consommer ta réponse en
quelques secondes.
- **Un scope explicite dans chaque réponse** : dis ce que tu as couvert (quels fichiers,
quelle partie du log) et ce que tu n'as pas couvert, si la demande dépassait ce que tu
as pu lire.
- **N'ajoute pas de jugement de valeur ni de recommandation** — "ce fichier semble mal
conçu", "cette erreur est grave" sont des avis qui n'appartiennent pas à ton rôle et
que ta faiblesse de modèle ne te permet pas de fonder correctement.
- **Reste bref.** Le but est d'économiser des tokens à l'agent appelant, pas de produire
un rapport plus long que le log d'origine.
---
## 4. Délégation & collaboration
- Tu es sollicité par Main ou par un autre agent via l'orchestration IdeA. Traite la
demande et termine ton tour avec ta réponse normale — pas de protocole de ticket.
- Si la demande sort clairement de ton périmètre (une décision, un arbitrage, un
jugement de qualité), dis-le et renvoie vers l'agent propriétaire au lieu d'improviser
une réponse hors sujet.
- Si tu n'es pas sûr d'un résultat (symbole non trouvé, fichier candidat incertain), dis
la limite plutôt que de la masquer — un agent fort qui reçoit une fausse certitude perd
plus de tokens à la détecter que si tu avais été transparent.

0
.ideai/agents/glm.md Normal file
View File

File diff suppressed because one or more lines are too long

View File

@ -34,5 +34,330 @@
"idea_template_delete"
]
},
"agents": []
"agents": [
{
"agentId": "a6ced819-b893-4213-b003-9e9dc79b9641",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
},
{
"agentId": "dce19c75-9669-4e45-b8de-9950025157da",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
},
{
"agentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
},
{
"agentId": "af7f86da-76bc-48e1-9900-71f45a624800",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
},
{
"agentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
},
{
"agentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
},
{
"agentId": "c3e0d9d8-df36-4b00-b271-62d8aa78892c",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
},
{
"agentId": "5d07e4a7-8676-4a71-aea1-5b64caefa944",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
},
{
"agentId": "30bf7e5f-5681-478d-813c-ac4c3957897f",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
}
]
}

View File

@ -67,3 +67,6 @@
- [ticket74-f1-web-bundle-transport-seam](ticket74-f1-web-bundle-transport-seam.md) — Deux artefacts Vite (dist/dist-web), mode `web` portable Windows, et le câblage anti-divergence de la constante de transport.
- [ticket43-backend-b1-b4-qa-validation](ticket43-backend-b1-b4-qa-validation.md) — memory note ticket43-backend-b1-b4-qa-validation
- [ticket43-plugin-system-final-qa-verdict](ticket43-plugin-system-final-qa-verdict.md) — memory note ticket43-plugin-system-final-qa-verdict
- [ticket101-cross-talk-multi-project-rootcause](ticket101-cross-talk-multi-project-rootcause.md) — memory note ticket101-cross-talk-multi-project-rootcause
- [ticket103-network-permission-ux-surface](ticket103-network-permission-ux-surface.md) — Stable UX convention for agent network permissions in IdeA.
- [codex-network-access-config-fix](codex-network-access-config-fix.md) — memory note codex-network-access-config-fix

View File

@ -0,0 +1,41 @@
---
name: codex-network-access-config-fix
description: memory note codex-network-access-config-fix
metadata:
type: project
---
# Correctif accès réseau Codex via `sandbox_workspace_write.network_access`
Le 2026-07-26, le diagnostic live a montré qu'un agent Codex lancé par IdeA échouait sur `curl` même avec IP forcée. La cause n'était pas seulement DNS ni une session à relancer : Codex CLI 0.145 attend la configuration officielle `sandbox_workspace_write.network_access=true` pour autoriser le réseau dans `workspace-write`.
Correctif implémenté et validé par QA dans les sources :
- Projection Codex `$CODEX_HOME/config.toml` : gérer `[sandbox_workspace_write] network_access = true/false` via `MergeToml`, pour éviter un stale `true`.
- `codex exec` structuré : passer `-c sandbox_workspace_write.network_access=<bool>` quand la policy est connue.
- La permission système IdeA reste séparée des permissions fichiers/bash : seul `NetworkPolicy::Allow` active `network_access=true`; `Deny`, `Ask` et `None` donnent `false`.
- L'env `CODEX_SANDBOX_NETWORK_DISABLED` est encore upserté comme garde anti-héritage stale, mais ce n'est plus le mécanisme principal.
Fichiers principaux :
- `crates/infrastructure/src/permission/codex.rs`
- `crates/domain/src/ports.rs`
- `crates/application/src/agent/lifecycle.rs`
- `crates/infrastructure/src/session/codex.rs`
- `crates/application/src/ticket_assistant.rs`
Validation QA verte :
- `cargo test -p infrastructure permission::codex`
- `cargo test -p infrastructure codex_`
- `cargo test -p application --test agent_lifecycle codex_`
- `cargo test -p application --test ticket_assistant codex_`
- `cargo test -p domain`
Build réalisé :
- `npm --prefix frontend run build` : vert.
- `tauri build --bundles appimage` : compilation release OK, mais bundling linuxdeploy a échoué car `appimagetool` tentait de télécharger le runtime sans réseau.
- Contournement appliqué : `appimagetool --runtime-file /home/anthony/.cache/tauri/runtime-x86_64 ...`.
- AppImage générée : `target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`, SHA-256 `08d74dfe950312f968f3fe5646d747f2ac6fa25f2a523a83825182f2809e2aa8`.
Validation live restante obligatoire : relancer IdeA depuis cette nouvelle AppImage, lancer un agent Codex frais avec `network: allow`, puis exécuter un vrai `curl`. La session Codex déjà active ne peut pas prouver le fix car elle a été lancée avant ces nouveaux arguments/config.

View File

@ -0,0 +1,59 @@
---
name: context-agent-token-offload-design
description: memory note context-agent-token-offload-design
metadata:
type: project
---
# Agent Context — allègement token, périmètre figé
Décision validée le 2026-07-23 : un nouvel agent **Context**, tournant sur un **LLM local peu
puissant**, a été ajouté au projet IdeA pour réduire la fréquence des limites de tokens atteintes
par les autres agents (Main, Architect, UX, DevBackend, DevFrontend, QA, Git) — **sans dégrader
la qualité de leurs décisions**.
## Périmètre retenu (mécanique, faible risque)
- Recherche de symboles (localisation définition/usage).
- Résumé de fichier(s) — factuel, pas d'interprétation architecturale.
- Classification d'erreurs de compilation (tri d'un log brut).
- Extraction des tests en échec (à partir d'une sortie de test brute).
- Résumé de diff (`git diff` volumineux → liste factuelle de fichiers/nature de changement).
- Repérage de fichiers probablement concernés par un ticket (piste, pas garantie).
## Explicitement exclu / dégradé en brouillon uniquement
Proposer un message de commit, générer un test unitaire, mettre à jour une documentation qui fait
foi : ce sont des artefacts qui engagent une décision et qui appartiennent aux agents propriétaires
(Git pour les commits, QA pour les tests). Un modèle local faible qui les produit comme livrable
ferait courir un risque de qualité que la vérification par l'agent fort annulerait de toute façon
le gain de tokens visé. Context peut produire un brouillon explicitement marqué comme tel, jamais
un livrable.
## Garde-fous de fiabilité (vu la faiblesse du modèle)
Context ne décide rien, ne corrige pas de code de production, ne remplace jamais une vérification
qui doit être prouvée (tests QA, verdict de build), et ne devine jamais quand l'information manque
— il doit dire "non trouvé"/"incertain" plutôt qu'extrapoler. Réponses courtes, structurées,
citant ce qui a été effectivement vu (chemins, lignes), sans jugement de valeur.
## Où c'est câblé
- Contexte agent : `.ideai/agents/context.md` (écrit via `idea_update_context`).
- Template global IdeA créé : « Context — Agent d'assistance légère à faible coût »
(`defaultProfileId` = profil LLM local de l'agent Context), pour réutilisation cross-projet.
Le template ne contient que la partie générique (aucune référence à IdeA le produit) ;
la déclaration des 7 autres rôles nommés reste project-spécifique et vit dans
`.ideai/agents/context.md` du projet, pas dans le template.
- Rôle ajouté à la liste des rôles du contexte projet global (CLAUDE.md §3) : Context ne fait
**pas** partie du cycle obligatoire (§4) — pas d'étape qui lui est dédiée, il est sollicité en
support ponctuel par n'importe quel agent.
- Chaque agent (Main, Architect, DevBackend, DevFrontend, QA, Git, UX) a reçu un ajout court dans
sa section « Délégation & collaboration » expliquant quand solliciter Context et rappelant que
son résultat est une piste à vérifier, jamais une conclusion ou une décision.
## Pourquoi
Voir [[idea-product-directives-main-handoff]] pour les directives produit générales ; cette note
couvre spécifiquement le compromis coût-tokens/qualité qui a motivé le périmètre volontairement
restreint de Context (pas de délégation de jugement, seulement de compression/extraction
factuelle).

View File

@ -1,19 +1,10 @@
---
name: ticket103-network-permission-ux-surface
description: memory note ticket103-network-permission-ux-surface
description: Stable UX convention for agent network permissions in IdeA.
metadata:
type: project
type: reference
---
---
title: "Ticket #103 - UX surface for network permission"
type: reference
description: "Stable UX convention for exposing network permission in IdeA: Permissions panel as primary editor, Agents as compact summary, Terminal as contextual failure surface, and explicit runtime-locked state."
---
# Ticket #103 — UX surface for network permission
- Keep a single primary edit surface in `Permissions > Système`.
- Mirror the effective network state in the Agents list as a compact badge.
- Use the Terminal only for contextual, actionable failures.
- Distinguish clearly between user policy, effective state, and runtime lock.
- If the runtime cannot elevate permission in the active session, the control must be read-only and the UI must say so explicitly.
- Editability must follow the resolved control state, not simply whether an agent exists.
- Non-launched agents stay editable unless a runtime lock is explicitly present.
- Read-only messaging should be explicit only for active locked runtimes, especially external/uninspectable runtimes.
- Effective policy labels must not be used as a proxy for editability.

View File

@ -0,0 +1,31 @@
---
name: ticket95-adapter-aware-liveness-probe
description: memory note ticket95-adapter-aware-liveness-probe
metadata:
type: project
---
# #95 — Sonde de vivacité du rendez-vous devenue adapter-consciente
**Type :** correctif architecture / bug racine. Livré sur `feature/rendezvous-liveness-probe-per-adapter` (commit 376fc9f), AppImage 0.3.0 rebuildée.
## Bug racine
Le rendez-vous `idea_ask_agent` (`run_inactivity_watchdog`, `crates/application/src/orchestrator/rendezvous.rs`) coupe en **faux `NoReply`** toute cible **structurée** dont le tour dépasse la fenêtre d'inactivité (défaut 600 s, = `turn_timeout_ms` du profil cible). Cause : la seule sonde de vivacité (`transcript_activity_token`) lisait le transcript Claude (`~/.claude/projects/<encoded-cwd>/*.jsonl`) — invisible pour OpenCode/Codex, et fragilise même Claude en mode `-p` headless si le transcript ne grossit pas mi-tour. `has_probe=true` mais la sonde renvoie `None` ⇒ bras `(true,_,None)=>false` ⇒ aucun réarmement ⇒ NoReply à la première fenêtre.
## Fix (architecture figée)
1. **Port domaine** (`crates/domain/src/ports.rs`) : `AgentSession::activity_token() -> Option<u64>` (défaut `None`, zéro régression). Jeton monotone = « la session travaille ».
2. **Machinerie** (`crates/infrastructure/src/session/process.rs`) : `run_turn_with_activity(...)` bump un `Arc<AtomicU64>` à **chaque ligne stdout lue** (drain async ET drain sandboxé thread). `run_turn` (4 args) délègue avec `None` ⇒ les ~11 call sites de test sont intacts.
3. **Sessions** : `ClaudeSdkSession`, `CodexExecSession`, `OpenAiCompatibleSession` overrident `activity_token()` via `run_turn_with_activity` ; `OpenCodeSession` (drain inline) bump son propre compteur par ligne JSONL.
4. **Sonde composite** (`resolve_ask_liveness_token`, `crates/backend/src/lib.rs`) : préfère `session.activity_token()` de la session vivante (`structured.session_for_agent`), repli inchangé sur le transcript Claude. Câblée par `.with_ask_liveness_probe`.
## Contrats clés
- `has_probe` reste `true` dès qu'une sonde est câblée ; la sonde composite renvoie désormais `Some(token)` qui avance ⇒ la fenêtre se réarme. Le repli transcript Claude couvre le cas « session vivante sans override ».
- La fenêtre = `turn_timeout_ms` du **profil cible** (`turn_timeout_for``liveness_for_agent`), défaut `ASK_AGENT_TIMEOUT` 600 s. **Pas d'env global pour la fenêtre** (seul le plafond a `IDEA_ASK_RENDEZVOUS_CEILING_MS`, défaut 4 h).
- Pour un test live rapide sans attendre 600 s : mettre un petit `turn_timeout_ms` (ex. 20 000) sur le profil de la cible, puis déléguer une tâche de ~60-90 s.
## État validation
- Tests unitaires verts : `run_turn_with_activity_bumps_counter_per_line`, `*_session_activity_token_advances_across_a_turn` (claude/codex/opencode), + la sonde composite backend + le watchdog rendezvous existants.
- AppImage 0.3.0 rebuildée + installée (`/home/anthony/Documents/IdeA_0.3.0_amd64.AppImage`, backup `.old-pre95fix`). **Relance IdeA requise** pour l'activer (le binaire qui tourne = AppImage en mémoire ; relancer depuis l'intérieur tue la session courante).
## À noter (dette / hors périmètre)
- #96 (EffectivePermissions assistants de ticket) laissé ouvert, documenté dans le carnet #96 (décision produit différée).
- Le souvenir utilisateur initial décrivait un échec « demandeur GLM/OpenCode → cible Claude ». Mécaniquement le watchdog est **ciblé-cible** (keyed sur l'agent cible) : un échec sur cible Claude longue n'est possible que si le transcript Claude `-p` ne grossit pas mi-tour (couvert désormais par l'`activity_token` de ClaudeSdkSession). La théorie principale reste « cible structurée longue non observée → faux NoReply ».

View File

@ -0,0 +1,35 @@
---
name: ticket97-opencode-provider-mutual-exclusion
description: memory note ticket97-opencode-provider-mutual-exclusion
metadata:
type: project
---
# Ticket #97 — Exclusion mutuelle opencode / opencodeProvider (décision Architect)
## Bug
Wizard first-run : profiles.json contient à la fois `opencode` (llamacpp) et `opencodeProvider` (cloud). Lecture priorise `opencode` → llamacpp l'emporte silencieusement.
## Root cause (vérifiée dans le code)
1. `SaveOpenCodeProviderProfile::execute` (`crates/application/src/agent/usecases.rs:364-367`) pose `opencode_provider` sans faire `opencode = None`.
2. Invariant `opencode_backend_is_consistent` (`crates/domain/src/profile.rs:1220`) existe + testé mais **jamais appelé** hors tests (garde morte).
3. Lecture priorise `opencode` : `crates/application/src/agent/lifecycle.rs:2367` + `crates/infrastructure/src/assistant/mod.rs:252`.
## Décisions Architect (validées)
- **Lot** : un seul lot cohérent. Backend = autoritaire (invariant + garde + use case via builder + migration). Frontend = strip de la config inactive au save selon le mode (requis pour les chemins SaveProfile/ConfigureProfiles qui ne portent pas d'intention explicite). Découplés/parallélisables. DevBackend + DevFrontend + QA.
- **Frontière** :
- Domaine : builders `with_opencode` (`profile.rs:1132`) et `with_opencode_provider` (`profile.rs:1140`) doivent imposer l'exclusion mutuelle (chacun efface l'autre). Aujourd'hui ce sont des setters muets = la faille.
- Infrastructure : `FsProfileStore::save` rejette tout profil violant l'invariant → `AppError::Invalid` (c'est ici que le prédicat enfin s'appelle). Défense en profondeur.
- Application : use cases utilisent les builders, jamais la mutation brute de champ.
- Refuser l'ad-hoc `opencode = None` par use case (DRY, 4 chemins d'écriture).
- **DTO** : garder `SaveOpenCodeProviderProfileRequestDto` (`dto.rs:1148`) whole-profile (zéro cassure frontend). Le `opencode` parasite devient inoffensif car le use case reconstruit via builder. Output = profil normalisé, autorité pour le frontend. Corriger aussi SaveProfile (`usecases.rs:269`) et ConfigureProfiles (`usecases.rs:460`).
- **Lecture priorité** : garder `opencode` d'abord (irrelevant post-exclusion), commenter comme fallback défensif.
- **Duplication résolution** (lifecycle.rs:2367 + assistant/mod.rs:252) : dette hexagonale préexistante, NE PAS élargir ce lot. Suivre via un futur `AgentProfile::effective_opencode_backend()` ou générateur partagé.
- **Migration REQUISE** : profils déjà corrompus sur disque portent les deux. Recovery : à la lecture (ou passe de migration), quand les deux présents, dropper `opencode` stale (l'intention est cloud, le bug ne venant que d'une action cloud explicite). Sans cela, #97 ne corrige que les nouveaux profils.
## Sites de résolution (priorité) = exactement 2
- `crates/application/src/agent/lifecycle.rs:2367`
- `crates/infrastructure/src/assistant/mod.rs:252`
Autres accès `.opencode` scoper en mode local, non affectés : `lifecycle.rs:2455-2468`, `model_server.rs:121-127`, `catalogue.rs:244-258`, `usecases.rs:410`.
## Statut
Décision Architect posée. À faire implémenter par DevBackend (domaine builders + garde store + migration + use cases) et DevFrontend (strip au save). QA : tests domaine/store + intégration cloud + scénario migration profils corrompus.

View File

@ -0,0 +1,83 @@
---
name: ticket98-opencode-modelsdev-cache-seed
description: memory note ticket98-opencode-modelsdev-cache-seed
metadata:
type: project
---
---
slug: ticket98-opencode-modelsdev-cache-seed
title: "Ticket #98 — Fix spawn OpenCode : seed du cache models.dev isolé (approche B2)"
type: reference
description: Cadrage figé du fix #98 (modèle écrasé par glm-5.2 au spawn pour provider catalogue cloud ex: zai) : cause racine = asymétrie picker↔spawn, approche B2 = semer best-effort le cache models.dev hôte dans le XDG_CACHE_HOME isolé.
---
# Ticket #98 — Fix spawn OpenCode : seed du cache models.dev isolé
## Symptôme
Wizard profil OpenCode first-run : provider catalogue cloud (ex: zai/ZAI Code) + modèle choisi → après sauvegarde l'agent tourne en `glm-5.2` quel que soit le modèle. Persistance correcte (`profiles.json` conserve `opencodeProvider.model`). `glm-5.2` est le **fallback interne d'OpenCode**, absent du code IdeA.
## Cause racine (confirmée + affinée par Architect)
**Asymétrie picker↔spawn**, pas seulement « pas de bloc models » :
1. **Picker** (`provider_catalogue.rs:79-84,150-153`) : lit le cache models.dev via `opencode_models_cache_path()` qui résout le **vrai** cache hôte (`XDG_CACHE_HOME ?? ~/.cache`). Permet d'afficher zai + ses modèles.
2. **Spawn** (`lifecycle.rs:2386,2400-2407` ; `assistant/mod.rs:272,277-285`) : IdeA **ré-isole** `XDG_CACHE_HOME``.opencode/cache` **vide** sous `run_dir`. OpenCode ne voit plus le cache.
3. **Rendu** (`opencode_provider_config_json`, `lifecycle.rs:2795-2821` / `assistant/mod.rs:446-466`) : pour `config.custom == None` (défaut pour zai), IdeA n'émet que `apiKey` + `model: "zai/<m>"`, **sans** bloc `models`. Correct **uniquement** pour les 3 built-ins hardcodés dans le binaire OpenCode (`anthropic`, `openai`, `openrouter`).
→ OpenCode reçoit `model: "zai/<m>"`, ne reconnaît pas zai comme built-in, ne trouve pas le cache models.dev (isolé vide) → fallback silencieux glm-5.2.
- **Local/custom marchent** : émettent un bloc `models` auto-suffisant (`assistant/mod.rs:364-381`, `:447-460`).
- **3 built-ins marchent** : hardcodés dans OpenCode.
- Bug ne touche **que** les providers connus d'OpenCode *exclusivement via models.dev*.
## Approche figée : **B2 — Semer le cache models.dev isolé depuis le cache hôte**
Après création du `XDG_CACHE_HOME` isolé, copier **best-effort** le fichier cache models.dev hôte (`opencode_models_cache_path()``<isolated_cache>/opencode/models.json`) via le port `FileSystem`. Lecture hôte = `std::fs` (identique au picker) ; écriture dans la home isolée.
**Pourquoi pas les autres :**
- **(A)** Émettre un bloc `models`+`npm`+`baseURL` pour les catalogue → **écartée en v1** : IdeA devrait capturer le bon `npm` AI-SDK par provider depuis models.dev (`@ai-sdk/anthropic` vs `openai-compatible`…) — dupliquerait la connaissance du registry OpenCode (violation OCP), risque de régression built-ins. Gardée en **escalade** si B2 défait par refresh.
- **(B1)** Pointer vers le vrai cache hôte (ne plus isoler) → **écartée** : casse l'isolation en écriture (pollution + race entre sessions).
- **(C)** Bundler une copie statique models.dev dans IdeA → **écartée** : staleness + diverge picker/spawn.
**Auto-cohérence B2** : la précondition du bug (cache hôte présent au picker) garantit la précondition du fix (fichier à copier au spawn). Cache hôte absent → picker ne montre que les 3 built-ins → bug non atteint → fix non requis.
## Contrat figé
### Ports / DTO
- **Aucun nouveau port / entité domaine / DTO modifié.** Réutilise le port `FileSystem` déjà injecté sur le chemin spawn. Lecture source = `std::fs` hôte (mécanisme identique au picker).
- Rendre **publique** `opencode_models_cache_path()` (source de vérité unique — encode le workaround du bug upstream #8235 ; ne **pas** re-dériver).
### Fichiers (périmètre DevBackend)
- `crates/application/src/agent/provider_catalogue.rs` — exposer `opencode_models_cache_path()` en `pub` (ou ajouter `pub fn read_models_dev_cache_bytes() -> Option<Vec<u8>>`).
- `crates/application/src/agent/mod.rs` — re-export.
- `crates/application/src/agent/lifecycle.rs` — branche `apply_mcp_config` (≈2382-2408) : après `create_dir_all` du cache + création `xdg_cache`, semer `<xdg_cache>/opencode/models.json` depuis le cache hôte, best-effort. Branche **commune** opencode + opencodeProvider.
- `crates/infrastructure/src/assistant/mod.rs` — branche miroir (≈268-285) : même appel.
- **Factoriser** : `application::agent::seed_opencode_models_cache(fs: &dyn FileSystem, isolated_cache_dir: &str)` appelée par les deux sites. Partie **pure** testable `seed_from_bytes(fs, dest, src_bytes)` ; wrapper impur `std::fs::read` fin.
### Invariants (stricts)
1. Isolation préservée en **écriture** : HOME, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_CACHE_HOME restent sous `run_dir`. Aucune var d'env repointée vers l'hôte. Seul le **contenu** du cache isolé est semé (lecture seule).
2. **Best-effort, n'échoue jamais le launch** : source absente/illisible ou erreur FS → pas d'échec du spawn. Symétrique du contrat best-effort existant de `apply_mcp_config` (`lifecycle.rs:2422-2425`). ≠ `resolve_opencode_provider_api_key` (échec dur). Le seed est **mou**.
3. Pas de régression local/custom : `opencode_config_json` (llamacpp) et `opencode_provider_config_json(custom==Some)` **byte-identiques** (rendu non touché ; seed additif orthogonal).
4. Pas de régression built-ins : anthropic/openai/openrouter (`custom==None`, hardcodés) restent fonctionnels.
5. Exclusion mutuelle `opencode` vs `opencodeProvider` (#97) : non touchée.
6. Source de vérité unique du chemin models.dev : `opencode_models_cache_path()` réutilisée.
7. Cohérence picker↔spawn : modèles résolvables au spawn = sur-ensemble de ceux du picker (même fichier source).
### Hors périmètre (ne pas faire ici)
- Résolution providers pour **projets remote (SSH/WSL)** (picker lit déjà le cache hôte — incohérence pré-existante). Fix corrige le cas **local**, ne régresse pas le remote.
- Déduplication du rendu lifecycle↔infra (`opencode_provider_config_json` ×2) — on factorise **uniquement le seeder**.
- Aucune modif UI/wizard.
## QA — 2 couches
1. **Unitaire `application`** (automatisable, déterministe) :
- Partie pure `seed_from_bytes(fs, dest, src_bytes)` : bytes présents → assert `<dest>/opencode/models.json` écrit identique ; `None` → aucun fichier **et** pas d'erreur.
- Non-régression rendu : JSON de `opencode_config_json` et `opencode_provider_config_json(custom==Some/None)` byte-identiques (tests existants `lifecycle.rs:4428+` verts).
- ⇒ Ne prouve **pas** la résolution réelle par OpenCode.
2. **Intégration / réelle-exécution (gated, propriété QA)** : spawn réel d'OpenCode avec profil `zai` (`custom==None`) contre un cache models.dev fixture contenant `zai` ; assert **pas de fallback glm-5.2** (modèle `zai/<choix>` effectivement utilisé). Gater `#[ignore]`/env (clé API + réseau). **C'est ce test qui prouve le bug corrigé.**
## Risque résiduel + escalade
- **Inconnu empirique** : est-ce qu'OpenCode au démarrage tente de **rafraîchir** models.dev (écrase notre copie, ou hang sans réseau) ? `OPENCODE_DISABLE_AUTOUPDATE=1` ne vise que l'auto-update du binaire, pas sûr qu'il couvre le refresh models. **QA doit vérifier** sur le test d'intégration. Si le refresh défait B2 → **escalader vers (A)** : capturer `npm`+`baseURL` réels par provider dans le parseur models.dev et peupler `custom`.
## Topologie
- Branche : `feature/ticket98-opencode-modelsdev-cache-seed` depuis `develop@69e5878` (#97 exclusion mutuelle prérequis y est fusionné : `0f0a76d` + merge `5533073`). `crates/` propre.
- Carnet #98 version 2 = source de vérité du cadrage.
## Lié
- #97 (relatesTo) : exclusion mutuelle provider — prérequis livré.

View File

@ -0,0 +1,53 @@
---
name: toolchain-rust-partage-acces-agents
description: memory note toolchain-rust-partage-acces-agents
metadata:
type: project
---
---
slug: toolchain-rust-partage-acces-agents
title: Accès au toolchain Rust/Tauri partagé pour tous les agents (isolement HOME OpenCode)
type: project
description: Le toolchain Rust stable est déjà installé sur la machine mais invisible aux agents car le profil OpenCode isole HOME. Diagnostic, atténuation par symlink, et chantier durable (injection env au spawn).
---
# Toolchain Rust/Tauri : pourquoi les agents ne compilaient pas, et comment les débloquer
## Symptôme (récurrent)
Tout agent (Main, DevBackend, QA,…) lancé sous un profil OpenCode ne peut PAS compiler le workspace Rust/Tauri :
- `cargo`/`rustc` = shims rustup qui répondent « no default toolchain » / « no installed toolchains ».
- Bloque toute validation backend (cargo check/build/test), fait timeout les délégations lourdes.
## Cause racine
- Le toolchain **stable 1.94.1 est DÉJÀ installé** dans `/home/anthony/.rustup` + `/home/anthony/.cargo` (toolchains + bin).
- MAIS le profil **OpenCode isole `HOME`** vers `.ideai/run/<agent_uuid>/.opencode/`. Donc `~/.rustup` et `~/.cargo` = `.opencode/.rustup` / `.opencode/.cargo`, qui ne contiennent qu'un `settings.toml` + un cache registry partiel → **aucun toolchain**.
- IdeA lui-même ne set **ni** `RUSTUP_HOME` **ni** `CARGO_HOME` (grep vide dans `crates/`). Le point d'extension existe pourtant : `crates/infrastructure/src/session/opencode.rs:237` (`cmd.env(key, value)`) set déjà des env au spawn.
- `target/` est dans le project root (partagé, géré par le lock cargo) → partageable sans conflit.
- `tauri` est dispo via `frontend/node_modules/.bin/tauri` (après `npm install` du frontend).
## Atténuation appliquée (2026-07-25, ops, sans rebuild)
Symlinker les homes isolés vers les homes partagés pour chaque run-dir agent :
```bash
for d in .ideai/run/*/.opencode; do
for sub in .rustup .cargo; do
p="$d/$sub"
[ -e "$p" ] && [ ! -L "$p" ] && { rm -rf "$p"; ln -s "/home/anthony/$sub" "$p"; }
done
done
```
Vérifié sur Main : `cargo 1.94.1` / `rustc 1.94.1` accessibles **sans aucune variable d'env explicite**. Symlink appliqué aux agents actifs (Main a6ced819, DevBackend 73c853d1, QA aefdbd61, …) — 5 run-dirs concernés.
**Tient pour les agents existants** : rustup suit les symlinks, et OpenCode ne recrée pas `$HOME/.rustup` s'il existe déjà.
## Solution DURABLE (chantier IdeA, à livrer)
Pour les **nouveaux** agents (IdeA crée un nouveau run-dir `.ideai/run/<uuid>/.opencode/.rustup` vide), il faut qu'IdeA provisionne l'accès au toolchain partagé automatiquement. Deux options équivalentes, à faire par DevBackend + **rebuild AppImage + relance** :
1. Au spawn de l'agent, injecter `RUSTUP_HOME=/home/anthony/.rustup` et `CARGO_HOME=/home/anthony/.cargo` (point d'extension `opencode.rs:237`). Valeurs résolues depuis le vrai HOME user (pas le HOME isolé), idéalement configurables (env projet / settings).
2. Ou, à la création du run-dir, créer les deux symlinks `.opencode/.rustup` et `.opencode/.cargo` → homes partagés.
Recommandation : option 1 (injection env) — moteur-agnostique (marche aussi pour Codex/Claude si un jour ils isolent aussi) et ne dépend pas du filesystem layout du moteur.
À grouper dans le **même rebuild AppImage** que le fix « sonde de vivacité multi-moteur » (cf. `idea-memory` à venir) — les deux sont des chantiers runtime qui nécessitent rebuild + relance.
## Liens
- Règle rebuild AppImage : `.ideai/skills/md/a72dac60-641c-4417-b0d7-94b8539f817a.md` (skill build AppImage), mémoire `mcp-bridge-and-delegation-runtime-notes`.
- Timeout délégation 600 s (autre symptôme sur les tâches lourdes) : mémoire `rendezvous-600s-cap-too-short-heavy-tasks` + sonde Claude-seule `crates/infrastructure/src/inspector/claude_paths.rs:89`.

View File

@ -0,0 +1,111 @@
# Build de l'AppImage IdeA (Linux)
Procédure canonique pour reconstruire l'AppImage Linux d'IdeA et les bundles frontend associés.
À utiliser à chaque fois qu'un correctif doit être visible dans l'app desktop. Rappel produit : **le binaire qui tourne = l'AppImage installée, pas les sources**. Un correctif source n'est actif dans l'app desktop qu'après rebuild, remplacement de l'AppImage utilisée, puis relance d'IdeA.
## Commande principale
Depuis le project root `/home/anthony/Documents/Projects/IdeA` :
```bash
cd crates/app-tauri
APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=true ../../frontend/node_modules/.bin/tauri build --bundles appimage
```
Cette commande déclenche `build.beforeBuildCommand`, donc elle reconstruit automatiquement les deux bundles frontend :
- `frontend/dist` : bundle desktop, transport `tauri`.
- `frontend/dist-web` : bundle web, transport `http`.
Ne pas revenir à l'ancienne procédure `npm --prefix frontend run build` seule : elle ne reconstruit pas le bundle web séparé.
## Vérification des bundles web/desktop
Après le build, lancer :
```bash
npm --prefix frontend run test:bundle-transport
```
Sortie attendue :
```text
dist: __IDEA_TRANSPORT__="tauri" (1 marker)
dist-web: __IDEA_TRANSPORT__="http" (1 marker)
```
## Artefact attendu
```text
target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage
```
Vérifier rapidement :
```bash
ls -lh target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage
APPIMAGE_EXTRACT_AND_RUN=1 target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage --appimage-help | head
```
## Pourquoi ces variables sont obligatoires
- `--bundles appimage` : cible uniquement l'AppImage Linux et évite les bundles inutiles/cassants comme NSIS.
- `NO_STRIP=true` : évite l'échec linuxdeploy/strip sur les bibliothèques système modernes contenant `.relr.dyn`.
- `APPIMAGE_EXTRACT_AND_RUN=1` : évite l'échec FUSE quand linuxdeploy ou appimagetool ne peuvent pas monter une AppImage dans l'environnement courant.
## Fallback si Tauri échoue à `failed to run linuxdeploy`
Symptôme : la commande Tauri reconstruit `frontend/dist`, `frontend/dist-web`, compile `target/release/app-tauri`, crée `target/release/bundle/appimage/IdeA.AppDir`, puis échoue seulement à l'étape finale :
```text
failed to bundle project `failed to run linuxdeploy`
```
Ne pas réanalyser tout le build. Faire ce diagnostic court :
```bash
APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=true LDAI_OUTPUT=/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage \
/home/anthony/.cache/tauri/linuxdeploy-x86_64.AppImage \
--appdir /home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA.AppDir \
--output appimage
```
Si `appimagetool` échoue avec téléchargement runtime impossible :
```text
Failed to download runtime file
```
utiliser le runtime local déjà en cache et l'`appimagetool` extrait. Chercher le dossier extrait récent :
```bash
find /tmp -path '*/appimagetool-prefix/usr/bin/appimagetool' -type f -printf '%p\n' | tail -1
```
Puis lancer en remplaçant `<EXTRACTED>` par le préfixe trouvé, par exemple `/tmp/appimage_extracted_xxx` :
```bash
PATH=<EXTRACTED>/appimagetool-prefix/usr/bin:$PATH \
ARCH=x86_64 \
<EXTRACTED>/appimagetool-prefix/usr/bin/appimagetool \
--runtime-file /home/anthony/.cache/tauri/runtime-x86_64 \
/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA.AppDir \
/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage
```
Le `PATH` est nécessaire parce que `mksquashfs` est fourni dans `appimagetool-prefix/usr/bin` et peut être absent du PATH système.
## Relance de l'application
Ne pas relancer IdeA automatiquement depuis une session active : cela tue l'orchestrateur courant et les ponts MCP. Une fois l'AppImage reconstruite, l'utilisateur décide quand remplacer/lancer l'artefact final.
## Piège d'environnement AppImage
Une session shell lancée depuis l'AppImage peut hériter de variables comme `APPDIR`, `LD_LIBRARY_PATH`, `PYTHONHOME` pointant vers `/tmp/.mount_IdeA_*`. Symptômes possibles : `python3` casse avec `No module named 'encodings'`, ou un binaire Tauri local se lance dans un environnement pollué.
Pour des commandes sensibles, préférer un environnement propre ou éviter Python :
```bash
env -i PATH=/usr/bin:/bin HOME=$HOME XDG_RUNTIME_DIR=/run/user/1000 <commande>
```

View File

@ -0,0 +1,6 @@
{
"version": 1,
"projectDefault": {
"network": "allow"
}
}

View File

@ -0,0 +1,6 @@
---
issueRef: "#100"
version: 3
updatedBy: {"kind":"user"}
updatedAt: 1784993984569
---

View File

@ -0,0 +1,16 @@
---
id: "4709958c-5082-44fd-a1fd-d6bad85f9361"
number: 100
title: "[Bug] Problème sur le scroll des agents OpenCode"
status: "open"
priority: "high"
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
links: []
agentRefs: []
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784992615586
updatedAt: 1784993984569
version: 3
---
Quand un agent est un agent opencode, je ne peux aps scroll très haut dans sa cellule.

View File

@ -1,8 +1,8 @@
---
issueRef: "#101"
version: 5
version: 7
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1785001809027
updatedAt: 1785011116365
---
# Carnet #101 — défaut d'isolation multi-projet (cause racine figée)
@ -74,11 +74,17 @@ Test d'intégration **non-cross-talk** : 2 projets ouverts avec **volontairement
- wake d'un projet non-actif fonctionne ;
- collision d'ids qui échouait avant → verte sur PTY, structured et background wake.
## Topologie
- Branche de travail : `feature/ticket101-multi-project-isolation` (créée par Git depuis
`develop` 6a87c46).
- `main` (da907b8), `feature/ticket99-agent-model-configuration` (da907b8),
`fix/terminal-resize-bug` (da907b8) : **preuves préservées**, à nettoyer après enquête.
## Clôture 2026-07-25
- Correctif livré sur `feature/ticket101-multi-project-isolation` puis mergé localement dans `develop`.
- Commit feature : `6e98fd8 fix(runtime): isolate agent state by project (#101)`.
- Merge local : `merge: integrate ticket 101 multi-project isolation`.
- QA ciblée verte :
- `cargo test -p application --test agent_wake --test orchestrator_service --test structured_registry_d1 --test structured_launch_d3 --test session_limit_service --test session_limit_t4 --test workstate --test workstate_actions`
- `cargo test -p infrastructure --test agent_inbox --test mcp_server`
- `cargo test -p app-tauri --test session_limit_wiring`
- `cargo test -p application`
- `cargo test -p app-tauri --tests`
- Réserve connue : `cargo test -p infrastructure` complet reste rouge dans le sandbox QA sur tests `openai_compat` à cause du bind local interdit (`Operation not permitted`), sans signal de régression #101.
## Hors périmètre
- #91 (popup de notification) : purement front, indépendant.

View File

@ -2,7 +2,7 @@
id: "05f9f05b-97d4-4ae2-9fd9-220dd71f7231"
number: 101
title: "[Bug] Soucis de retour de notification sur les taches backend"
status: "open"
status: "closed"
priority: "critical"
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
links: []
@ -10,7 +10,7 @@ agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}
createdBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784993230013
updatedAt: 1785001809027
version: 5
updatedAt: 1785011116365
version: 7
---
Lorsque un agent Opencode lance une tache backend, il s'arrete de travailler et je ne suis pas sur q'uil y ai un jour un retour de notification. Je ne sais aps si le soucis provient de OpenCode ou s'il provient du model (GLM 5.2 ici)

View File

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

View File

@ -0,0 +1,16 @@
---
id: "e91fd358-da94-4382-aa70-6a3fe5a63840"
number: 102
title: "[Bug] Devoir resize les cellule pour afficher la TUI d'un agent"
status: "open"
priority: "medium"
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
links: []
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784993319700
updatedAt: 1784993980505
version: 4
---
J'ai toujours un soucis qui fait que quand je switch de projet IdeA ou de layout ou que j'ajoute des cellules ou autres, je suis obligé de resize un coup la cellule pour que son constenu s'affiche correctement

View File

@ -1,6 +1,6 @@
---
issueRef: "#91"
version: 3
version: 4
updatedBy: {"kind":"user"}
updatedAt: 1784652150003
updatedAt: 1784994105390
---

View File

@ -4,13 +4,13 @@ number: 91
title: "Sur la notification de fin de tache backend, mettre plutot les deux agents en conversation"
status: "open"
priority: "medium"
sprint: null
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
links: []
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784652125081
updatedAt: 1784652150003
version: 3
updatedAt: 1784994105390
version: 4
---
Sur la notification de fin de tache backend, mettre plutot les deux agents en conveersation et qui a lancé l'appel (par exemple Main->DevBackend) pour que ça soit un peu plus explicite

View File

@ -1,8 +1,8 @@
---
issueRef: "#92"
version: 8
version: 9
updatedBy: {"kind":"user"}
updatedAt: 1784788822167
updatedAt: 1784993956910
---
## Résumé de livraison

View File

@ -2,7 +2,7 @@
id: "9ba8d2f4-49fa-4536-a38e-891f1df3e3b7"
number: 92
title: "Configurer OpenCode avec un provider Opencode"
status: "open"
status: "closed"
priority: "high"
sprint: null
links: []
@ -10,7 +10,7 @@ agentRefs: []
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784729443275
updatedAt: 1784788822167
version: 8
updatedAt: 1784993956910
version: 9
---
On peut actuellement utiliser un profile AI qui utilise OpenCode pour llamacpp. J'aimerais que l'on puisse configurer des profil AI OpenCode avec llamacpp comme actuellement, mais aussi en selectionnant un provider listé dans la commande /connect de OpenCode. Je pense que le mieux serait que lors de la création d'un profile AI OpenCode, il nous soit demandé si on souhaite utiliser LlamaCpp ou un provider OpenCode. Dans le cas d'un provider llamaCpp, on utilise la même chose qu'actuellement, dans l'autre cas on nous demande quel provider et il faudrait être capable de récupérer la liste fournie par OpenCode. Une fois le provider selectionné, il faudra que l'utilisateur puisse entrer une clé API car les providers opencodes en demadnent toujours un. Pour la partie utilisation ensuite d'opencode dans les agent, je pense que cette page peut aider: https://opencode.ai/docs/fr/cli/. On y trouve entre autre la commande pour se connecter avec opencode auth login. Le but est que la partie configuration se fasse dans les profil AI comme jusqu'à présent, et que l'utilisateur puisse directement attribuer un agent à son profile AI et le lancer sans configurer d'autre choses. OpenCode marche déjà correctement avec llamacpp, j'insiste sur le fait qu'on ne fait qu'ajouter la possibilité de configurer un provider autre que llamacpp

View File

@ -0,0 +1,46 @@
---
issueRef: "#93"
version: 3
updatedBy: {"kind":"user"}
updatedAt: 1784934179780
---
# Contexte
Test manuel demandé par l'utilisateur pour valider la conversation inter-agent : Main a appelé `idea_ask_agent(target="Context", task="...")` avec une tâche de simple confirmation d'identité/rôle.
## Observation
Les deux appels (identiques) ont renvoyé, à la place d'une réponse finale en langage naturel, exactement ce fragment JSON brut :
```json
{
"name": "idea_idea_context_read",
"arguments": {}
}
```
- Le nom d'outil est corrompu : préfixe `idea_` dupliqué (`idea_idea_context_read` au lieu de `idea_context_read`).
- Le fragment a la forme d'un appel d'outil (tool_use), pas d'un texte de réponse — semble être une tentative de Context de lire son contexte projet via `idea_context_read`, jamais exécutée, puis remontée telle quelle comme si c'était le Final capturé.
- Reproduit à l'identique sur 2 tentatives consécutives → pas un aléa de génération, plutôt un bug de câblage/capture.
## Détails agent Context
- id: `5d07e4a7-8676-4a71-aea1-5b64caefa944`
- contextPath: `agents/context.md`
- profileId: `a7037cbe-6d04-48fb-ba74-f353c93fc70a` (profil différent de celui des autres agents du manifeste, qui partagent tous `664cc20c-47b8-53ad-9351-dce3c09c0de4` — Context est le seul sur ce profil)
- origin: scratch
## Pistes d'investigation pour Architect / DevBackend
1. Le profil `a7037cbe-...` (LLM local léger dédié à Context, cf. mémoire `agent-context-memory-and-profile-handoff`) semble ne pas capturer correctement le "Final" du tour — le pont MCP/runtime renverrait le premier tool_use brut au lieu d'attendre la réponse texte finale.
2. Vérifier le mapping des noms d'outils exposés à ce profil : la duplication `idea_idea_*` suggère un préfixage appliqué deux fois (une fois côté définition d'outil MCP, une fois côté wrapper du profil/pont).
3. Comparer avec le comportement des autres profils (`664cc20c-...`) qui fonctionnent correctement en inter-agent, pour isoler ce qui diffère structurellement dans le pont pour un profil local/structured.
4. Voir mémoire projet `mcp-bridge-and-delegation-runtime-notes` pour les pièges déjà connus du pont MCP/délégation — possible recoupement.
## Repro
Depuis Main :
```
idea_ask_agent(target="Context", task="<n'importe quelle tâche simple>")
```
→ renvoie le fragment JSON ci-dessus au lieu d'une réponse.

View File

@ -0,0 +1,27 @@
---
id: "40f56f7d-b0ec-4409-bf6b-c010e3003b76"
number: 93
title: "Agent Context : réponse finale non capturée, fragment d'appel d'outil brut renvoyé via idea_ask_agent"
status: "closed"
priority: "medium"
sprint: null
links: []
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
createdAt: 1784817692650
updatedAt: 1784934179780
version: 3
---
Lors d'un test de la conversation inter-agent (idea_ask_agent ciblant Context), l'appel renvoie systématiquement un fragment JSON brut au lieu d'une réponse finale exploitable :
```json
{
"name": "idea_idea_context_read",
"arguments": {}
}
```
Le nom d'outil est corrompu (préfixe "idea_" dupliqué : "idea_idea_context_read" au lieu de "idea_context_read"), et ce fragment ressemble à une tentative d'appel d'outil non exécutée, renvoyée telle quelle comme si c'était la réponse finale du modèle.
Comportement reproduit deux fois de suite à l'identique, donc pas un aléa ponctuel.

View File

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

View File

@ -0,0 +1,48 @@
---
id: "33a844e0-6a06-46c7-b800-d3497a7f45f4"
number: 94
title: "Permissions OpenCode ignorées : opencode.json code en dur bash/edit=ask au lieu de projeter EffectivePermissions"
status: "closed"
priority: "high"
sprint: null
links: []
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784821271815
updatedAt: 1784823278030
version: 2
---
## Bug
Les permissions configurées dans IdeA pour un agent sous profil OpenCode ne sont PAS appliquées dans le `opencode.json` généré. Le bloc `permission` est codé en dur à `{"bash":"ask","edit":"ask"}` quel que soit le réglage IdeA.
## Repro
Config IdeA (agent OpenCode) : Read=Allow, Write=Deny, Delete=Allow, bash=Allow.
`opencode.json` généré (run dir) : `"permission":{"bash":"ask","edit":"ask"}`.
Attendu : `bash` reflète la posture bash (Allow→"allow"), `edit` reflète la posture Write (Deny→"deny").
Claude et Codex appliquent correctement les permissions ; seul OpenCode est touché.
## Cause racine
Le pattern de projection des permissions (`PermissionProjector` + `ProjectorKey::{Claude,Codex}` dans `crates/infrastructure/src/permission/`) est bypassé pour OpenCode. Les générateurs `opencode.json` codent `permission` en dur à 4 endroits :
- `crates/application/src/agent/lifecycle.rs:2728-2734` (`opencode_config_json`, variante llamacpp)
- `crates/application/src/agent/lifecycle.rs:2814-2820` (`opencode_provider_config_json`, variante cloud)
- `crates/infrastructure/src/assistant/mod.rs:406-412` (`opencode_config_json`, doublon)
- `crates/infrastructure/src/assistant/mod.rs:494-500` (`opencode_provider_config_json`, doublon)
Les call sites (`lifecycle.rs:2361-2378` branche `OpenCodeConfig`, et `assistant/mod.rs:247-256`) ne passent pas les `EffectivePermissions` résolues aux générateurs.
## Mapping attendu (à confirmer par Architect)
OpenCode `permission` = map tool→"allow"|"ask"|"deny". IdéA → OpenCode :
- `bash` ← posture bash effective
- `edit` ← posture Write effective
- Read/Delete ne sont pas exprimables dans opencode (pas de clé read/delete) — déjà enforcees par le sandbox Landlock (mémoire `permissions-sandbox-system-state`).
## Périmètre
Backend Rust pur. Validation possible par tests unitaires sur les générateurs (`opencode_provider_config_json` est déjà testé à lifecycle.rs:4413 et assistant/mod.rs:639) sans rebuild AppImage. Validation live = rebuild AppImage + relance IdeA.

View File

@ -0,0 +1,36 @@
---
issueRef: "#95"
version: 6
updatedBy: {"kind":"user"}
updatedAt: 1784934174326
---
## Cause racine RÉELLE (2026-07-24, après investigation code)
### Ce qui se passe
Le `"timeout": 15000` (15 secondes) **hardcoded** dans le bloc `mcp.idea` de l'`opencode.json` généré par IdeA. OpenCode applique ce timeout **par appel `tools/call`** à ses serveurs MCP. `idea_ask_agent` bloque pendant que l'agent cible travaille (potentiellement des minutes) → OpenCode tue la requête à 15s avec l'erreur `-32001: Request timed out`.
**Pourquoi Claude/Codex ne sont pas affectés** : leur config MCP (`.mcp.json` / `config.toml`) n'a **pas** de champ `timeout`. Ce champ est spécifique à la config OpenCode.
### Emplacements du hardcoded 15000 (4 occurrences)
1. `application/src/agent/lifecycle.rs:2728``opencode_config_json()`
2. `application/src/agent/lifecycle.rs:2811``opencode_provider_config_json()`
3. `infrastructure/src/assistant/mod.rs:410``opencode_config_json()` (duplicate)
4. `infrastructure/src/assistant/mod.rs:495``opencode_provider_config_json()` (duplicate)
### Fix appliqué (feature/ticket95-opencode-mcp-timeout)
- Constante `DEFAULT_OPENCODE_MCP_TIMEOUT_MS` = 4h (14_400_000 ms), alignée sur `DEFAULT_RENDEZVOUS_CEILING` — le client MCP ne expire jamais avant le watchdog serveur.
- Fonction `resolve_opencode_mcp_timeout_ms()` — overridable via `IDEA_OPENCODE_MCP_TIMEOUT_MS`, fallback sûr sur 0/parse-error.
- Les 4 occurrences remplacées par `resolve_opencode_mcp_timeout_ms()`.
- Exporté depuis `application::agent` pour réutilisation par `infrastructure`.
- **Claude/Codex non touchés** : aucune modification de leur config MCP.
### Tests
- Application : 115 passed, 0 failed
- Infrastructure : 313 passed, 0 failed
- Compilation : OK, aucun nouveau warning
### Note déploiement
Le `opencode.json` est régénéré à chaque lancement d'agent par le code lifecycle. Nécessite rebuild AppImage pour que le binaire qui tourne génère la nouvelle config.

View File

@ -0,0 +1,54 @@
---
id: "611077a6-f703-44b9-935b-64f8301085bb"
number: 95
title: "Rendez-vous idea_ask_agent : timeout MCP OpenCode trop court (15s), le demandeur OpenCode ne reçoit jamais la réponse"
status: "closed"
priority: "high"
sprint: null
links: []
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
createdAt: 1784822537740
updatedAt: 1784934174326
version: 6
---
## Bug
Une délégation `idea_ask_agent` depuis un agent sous profil **OpenCode** (le **demandeur**) échoue systématiquement en `timeout` (-32001 « Request timed out »), peu importe la cible (Claude, Codex, ou OpenCode). Les agents cibles terminent bien leurs tâches (visible dans le workstate = done), mais leurs réponses ne sont jamais livrées au demandeur OpenCode.
Claude et Codex ne sont **pas** affectés, qu'ils soient demandeur ou cible.
## Cause racine (vérifiée dans les sources)
Le `"timeout": 15000` (15 secondes) **hardcoded** dans le bloc `mcp.idea` de l'`opencode.json` généré par IdeA. OpenCode applique ce timeout **par appel `tools/call`** à ses serveurs MCP. `idea_ask_agent` bloque pendant que l'agent cible travaille (potentiellement des minutes) → OpenCode tue la requête à 15s.
**Pourquoi Claude/Codex ne sont pas affectés** : leur config MCP (`.mcp.json` / `config.toml`) n'a **pas** de champ `timeout`. Ce champ est spécifique à la config OpenCode (`opencode.json``mcp.idea.timeout`).
### Emplacements (4 occurrences de `15000`)
1. `crates/application/src/agent/lifecycle.rs``opencode_config_json()`
2. `crates/application/src/agent/lifecycle.rs``opencode_provider_config_json()`
3. `crates/infrastructure/src/assistant/mod.rs``opencode_config_json()` (duplicate)
4. `crates/infrastructure/src/assistant/mod.rs``opencode_provider_config_json()` (duplicate)
## Ce qui n'est PAS la cause
- Ce n'est pas un problème de sonde de vivacité du rendez-vous (la description originale pointait la sonde Claude-only côté cible — mauvaise piste).
- Ce n'est pas un problème de permissions, de sandbox, ou de taille de payload.
- Ce n'est pas lié au modèle : tout profil `structuredAdapter: "openCode"` est touché en tant que demandeur.
## Fix appliqué (feature/ticket95-opencode-mcp-timeout)
- Constante `DEFAULT_OPENCODE_MCP_TIMEOUT_MS` = 4h (14_400_000 ms), alignée sur `DEFAULT_RENDEZVOUS_CEILING`.
- Fonction `resolve_opencode_mcp_timeout_ms()` — overridable via `IDEA_OPENCODE_MCP_TIMEOUT_MS`, fallback sûr.
- Les 4 occurrences remplacées.
- **Claude/Codex non touchés**.
## Tests
Application : 115 passed. Infrastructure : 313 passed. 0 failed.
## Déploiement
Nécessite rebuild AppImage (l'`opencode.json` est régénéré à chaque lancement d'agent).

View File

@ -0,0 +1,18 @@
---
issueRef: "#96"
version: 3
updatedBy: {"kind":"user"}
updatedAt: 1784994119004
---
## Décision (2026-07-24) : différée — documentée, non codée
Sur instruction utilisateur (#96 mis en pause pour se concentrer sur #95) : **on documente le constat et la décision de fond reste ouverte pour plus tard**. Aucun code ajouté.
### Constat confirmé dans les sources
`crates/infrastructure/src/assistant/mod.rs::TicketAssistantEnvironmentPreparer::materialise_mcp` passe `eff: None` à `opencode_config_json` / `opencode_provider_config_json` (lignes ~253 et ~262). Le commentaire local (l. 246-251) le documente déjà : aucun `PermissionStore`/`EffectivePermissions` n'est résolu sur ce chemin, **pour aucun adaptateur**. Conséquence : les assistants de ticket tournent toujours avec le comportement **natif** d'OpenCode (prompting à chaque action), indépendamment des permissions IdeA configurées.
### Pourquoi c'est différé (la vraie question produit)
Les assistants de ticket sont lancés depuis un **profil** (pas depuis un **agent** avec une politique par-agent). Il n'y a donc **pas de source naturelle** d'`EffectivePermissions` sur ce chemin. Câbler nécessite d'abord de trancher : les permissions viennent-elles du profil ? d'un fallback projet ? d'une posture dédiée aux assistants ? Tant que ce choix produit n'est pas posé, `eff: None` (= prompting natif) reste le défaut **sûr** — ce n'est pas une régression, c'est le comportement historique préservé.
### Suivi
Non bloquant, priorité basse. À reprendre quand un besoin produit le justifie : définir la source d'autorités pour un assistant de ticket, puis la résoudre ici (comme pour les agents normaux dans `application::agent::lifecycle`).

View File

@ -0,0 +1,18 @@
---
id: "d5993d21-307b-4fc8-93f9-2bfbf44223d8"
number: 96
title: "Les assistants de ticket (tous adaptateurs) n'ont aucune résolution EffectivePermissions — permission toujours native/absente"
status: "open"
priority: "low"
sprint: "883534aa-7fc1-4d83-a9c0-17cac4a4eea5"
links: []
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
createdAt: 1784823281794
updatedAt: 1784994119004
version: 3
---
Trouvé en implémentant #94 (projection des permissions dans opencode.json). Le call site des assistants de ticket dans `crates/infrastructure/src/assistant/mod.rs` n'a **aucun** `PermissionStore`/`EffectivePermissions` câblé, pour aucun adaptateur (Claude, Codex, OpenCode) — vérifié par grep sur la composition root `crates/backend/src/lib.rs`. Le fix #94 y passe donc `eff: None` (préserve le comportement natif existant, pas de régression), mais ce n'est pas un vrai fix de fond : les permissions IdeA configurées pour un agent n'ont jamais été appliquées aux assistants de ticket, quel que soit l'adaptateur.
À trancher par Architect : soit câbler la résolution `EffectivePermissions` pour ce call site (comme pour les agents normaux), soit documenter que c'est un choix produit intentionnel (permanent) et fermer sans y toucher. Non bloquant, priorité basse.

View File

@ -0,0 +1,28 @@
---
issueRef: "#97"
version: 3
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784915626417
---
## Décision Architect (cadrage validé)
Bug 100% backend, deux défauts composés :
1. `SaveOpenCodeProviderProfile::execute` (`usecases.rs:364-367`) pose `opencode_provider` sans `opencode = None`.
2. Invariant `opencode_backend_is_consistent` (`profile.rs:1220`) existe mais jamais appelé (garde morte).
Lecture priorise `opencode` → cloud écrasé (`lifecycle.rs:2367`, `assistant/mod.rs:252`).
### Périmètre du lot (un seul, parallélisable)
- **DevBackend** :
- Domaine : `with_opencode`/`with_opencode_provider` (`profile.rs:1132,1140`) imposent l'exclusion mutuelle (chacun efface l'autre).
- Infrastructure : `FsProfileStore::save` rejette tout profil incohérent → `AppError::Invalid` (active enfin le prédicat).
- Application : `SaveOpenCodeProviderProfile` reconstruit via builder (`with_opencode_provider`) ; idem pour `SaveProfile`/`ConfigureProfiles`.
- **Migration requise** : à la lecture (ou passe dédiée), quand `opencode` ET `opencodeProvider` présents → dropper `opencode` stale (l'intention est cloud). Sinon ne corrige que les nouveaux profils.
- **DevFrontend** : strip de la config inactive au save selon le mode courant (requis pour SaveProfile/ConfigureProfiles qui ne portent pas d'intention explicite ; backend reste autorité via la garde).
- **QA** : tests unitaires domaine (builders exclusifs) + garde store + intégration création profil cloud + scénario migration profils corrompus.
### Décisions de frontière
- DTO `SaveOpenCodeProviderProfileRequestDto` **inchangé** (whole-profile) ; output = profil normalisé, autorité pour le frontend.
- Priorité lecture `opencode` d'abord **gardée** (irrelevant post-exclusion), commenter comme fallback défensif.
- Duplication de la résolution (lifecycle.rs:2367 + assistant/mod.rs:252) = dette hexagonale préexistante, **hors périmètre de ce lot** (suivre, ne pas refactoriser ici).
Voir mémoire projet `ticket97-opencode-provider-mutual-exclusion`.

View File

@ -0,0 +1,26 @@
---
id: "fd7057dc-0177-41e1-811c-701c802e81d3"
number: 97
title: "Duplication de provider lors de la création de profil AI - llamacpp ET provider cloud inclus simultanément"
status: "closed"
priority: "high"
sprint: null
links: []
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784899606438
updatedAt: 1784915626417
version: 3
---
Lors de la création d'un profil AI dans le premier-run wizard, le système génère un profiles.json qui contient à la fois la section `opencode` (llamacpp) et `opencodeProvider` (cloud), quel que soit le choix de l'utilisateur.
**Comportement attendu :**
- Si l'utilisateur choisit "llamacpp" : seule la section `opencode` doit être présente
- Si l'utilisateur choisit "provider cloud" : seule la section `opencodeProvider` doit être présente
**Comportement actuel :**
Les deux sections sont présentes simultanément, ce qui fait que llamacpp est priorisé même quand l'utilisateur veut utiliser un provider cloud (ex: ZAI/GLM).
**Cas de test :**
Création du profil GLM5.2 avec provider ZAI Code → profiles.json contient `opencodeProvider` correct MAIS contient aussi une section `opencode` vide ou inutile, ce qui force le fallback sur llamacpp.

View File

@ -0,0 +1,87 @@
---
issueRef: "#98"
version: 5
updatedBy: {"kind":"user"}
updatedAt: 1784983186248
---
# Carnet #98 — Contrat figé (approche **B2**)
> Cadrage ARCHITECT figé et gelé. **Source de vérité pour DevBackend et QA.** Ce carnet remplace la phase d'arbitrage ; les options A/B1/C sont closes (voir §2 pour le rejet motivé).
---
## 1. Cause racine affinée
Le modèle est perdu **au spawn**, pas à la sauvegarde (persistance vérifiée correcte bout en bout, `profiles.json` conserve bien `opencodeProvider.model`). Deux facteurs composés :
1. **Pas de bloc `models`** pour les providers catalogue connus — `opencode_provider_config_json` (`lifecycle.rs:2774-2844` + jumeau `assistant/mod.rs:428-503`) n'émet `npm`/`baseURL`/`models` **que** pour le chemin `custom`. Pour un provider catalogue (zai), seul `provider.<id>.options.apiKey` est écrit. Test fige ce manque : `lifecycle.rs:4439-4440`.
2. **Cache models.dev isolé et vide** — IdeA passe `XDG_CACHE_HOME=<run_dir>/.opencode/cache` vide (`lifecycle.rs:2386,2405` ; `assistant/mod.rs:272,282`) + `HOME` isolé. Or `zai` n'est connu d'OpenCode **que** via ce cache (catalogue lu côté IdeA depuis le vrai `~/.cache/opencode/models.json`, `provider_catalogue.rs:79-84`).
→ OpenCode ne connaît plus `zai`, ne résout pas `zai/<modèle>`**fallback silencieux `glm-5.2`** (fallback interne CLI OpenCode, absent du code IdeA).
Asymétrie confirmée : local llama.cpp et cloud custom marchent car ils émettent un bloc `models` (`assistant/mod.rs:371-379`, `:457-460`).
## 2. Approche retenue : **B2** (seed best-effort du cache hôte)
Copier best-effort le cache models.dev hôte (`opencode_models_cache_path()`) vers `<xdg_cache>/opencode/models.json` dans le cache isolé, juste après le `create_dir_all` du cache dans `apply_mcp_config` (branche commune opencode/opencodeProvider), aux **DEUX** sites.
**Rejet motivé des alternatives :**
- **A (émettre bloc `models` pour providers connus)** : rejeté car `provider_catalogue.rs` ne persiste que `name`+`models` et **ignore `npm`+`baseURL`**. Un bloc `models` seul laisse OpenCode incapable de *joindre* `zai` — il faudrait en plus étendre le domaine (`display_name`/`npm`/`base_url`) → scope bien plus large et risque régression. A n'est viable qu'en révision A-étendue, écartée pour ce lot.
- **B1 (= B sans factorisation, deux sites copiés)** : rejeté pour dette hexagonale — le writer `opencode_provider_config_json` est déjà dupliqué `lifecycle.rs` vs `assistant/mod.rs`. Dupliquer aussi le seed amplifierait la dette et rendrait toute divergence un bug silencieux. B2 impose une factorisation unique.
- **C (hybride : pointer `XDG_CACHE_HOME` vers le vrai cache)** : rejeté — casse l'invariant d'isolation du run (un profil pourrait lire/écrire le cache hôte ou un cache d'un autre run), et OpenCode pourrait y écrire (logs, refresh) → pollution mutuelle.
**Pourquoi B2 :** donne à OpenCode sa connaissance registry complète (npm, baseURL, modèles) sans toucher au domaine ni casser l'isolation écriture — c'est de la donnée publique read-only copiée **dans** l'espace isolé.
## 3. Contrat figé — ports / fichiers / invariants
### 3.1 Fonctions à implémenter (factorisation)
- **Rendre `opencode_models_cache_path()` publique** — source de vérité unique du chemin hôte, **ne pas re-dériver** le chemin dans le seed.
- **`seed_opencode_models_cache(fs, isolated_cache_dir)`** (fonction partagée) : lit le cache hôte via `opencode_models_cache_path()`, copie best-effort vers `<isolated_cache_dir>/opencode/models.json`. Appelée aux **deux** sites après le `create_dir_all` du cache dans `apply_mcp_config` (branche commune `opencode`/`opencodeProvider`).
- **`seed_from_bytes(fs, dest, src_bytes)`** (partie pure testable) : extrait la logique d'écriture du fichier destination à partir de bytes source. C'est le seam de test unitaire.
### 3.2 Sites d'appel (les DEUX)
1. `crates/application/src/agent/lifecycle.rs` — après `create_dir_all` cache dans `apply_mcp_config`.
2. `crates/infrastructure/src/assistant/mod.rs` — après `create_dir_all` cache dans `apply_mcp_config`.
### 3.3 Invariants (à respecter, à tester)
- **Isolation préservée en écriture** : le seed ne fait que copier une donnée read-only **vers** le cache isolé. Jamais de `XDG_CACHE_HOME` pointé vers l'hôte.
- **Best-effort** : le seed **n'échoue jamais le launch**. Toute erreur (fichier hôte absent, IO) est tracée (warn/log) et ignorée — on retombe sur le comportement actuel (fallback glm-5.2), pas sur un crash.
- **Pas de régression** : profils local (llama.cpp), custom et built-ins (anthropic/openai/openrouter) doivent continuer à fonctionner byte-identique au rendu `opencode.json` actuel.
- **Exclusion mutuelle #97 non touchée** : le seed s'exécute sur la branche commune `opencode`/`opencodeProvider`, sans affecter la logique d'exclusion.
- **Cohérence picker↔spawn** : le catalogue vu côté UI (picker) et celui seedé au spawn proviennent du même fichier hôte → l'utilisateur ne peut pas picker un modèle qu'OpenCode ne connaîtra pas au spawn.
### 3.4 Hors périmètre (explicitement exclu)
- Remote SSH/WSL (le seed ne concerne que le spawn local).
- Déduplication du rendu lifecycle↔infra au-delà du seed (la dette du writer `opencode_provider_config_json` reste ouverte — autre lot).
- UI wizard (aucun changement surface).
## 4. Périmètre QA — 2 couches
### Couche 1 — Unitaire pure (obligatoire, rapide, déterministe)
- **`seed_from_bytes`** : assert écriture byte-identique du contenu source vers `dest`, gestion erreurs (fs en échec → pas de panic), idempotence.
- **Non-régression rendu `opencode.json`** : les tests existants `lifecycle.rs:4439-4440` et équivalents infra doivent rester **byte-identiques** pour local/custom/built-ins (le seed ne change pas le rendu config). Mettre à jour l'assert si et seulement si B2 modifie réellement le rendu — sinon la conserver telle quelle.
### Couche 2 — Intégration gated (obligatoire avant fermeture du lot)
- **Spawn réel OpenCode** sur profil `zai` + modèle X choisi.
- **Assert** : OpenCode démarre sur le modèle X (pas de fallback `glm-5.2`).
- Vérifier dans les background-tasks/IO qu'aucune trace `glm-5.2` n'apparaît comme modèle actif.
- **Gate obligatoire à lever** : OpenCode **refresh/écrase-t-il le cache `models.json` au démarrage** ? → si **oui**, le seed est inutile (écrasé avant lecture) et **il faut escalader vers A-étendue** (persistir npm/baseURL/models côté domaine). Consigner le verdict dans le rapport QA.
## 5. Risque résiduel — refresh models.dev par OpenCode
**Risque ouvert, à trancher en QA couche 2.** Si OpenCode rafraîchit/écrase `<xdg_cache>/opencode/models.json` au démarrage (network call ou réécriture locale), le seed B2 est potentiellement **inopérant** :
- Meilleur cas : OpenCode lit le cache avant tout refresh → B2 fonctionne.
- Cas dégradé : OpenCode refresh en premier, écrase le seed, et comme le profil est isolé (pas d'accès réseau garanti / HOME isolé), le refresh peut échouer ou produire un cache incomplet → bug persiste.
- **Plan de contournement si échec** : escalader vers A-étendue (ajouter `npm`+`base_url`+`models` persistés côté `OpenCodeProviderConfig` + émettre le bloc complet au spawn). Ce plan est documenté mais **hors scope B2** — il ferait l'objet d'un lot suivant si QA le confirme nécessaire.
---
## Contexte de mise à jour
- Cadrage figé par Main sur validation Architect (approche B2).
- Version précédente (v1) : phase d'arbitrage A/B/C — clos.
- Prochaine étape cycle : Git (branche) → DevBackend (implémentation 2 sites + factorisation) → QA (2 couches, gate refresh).

View File

@ -0,0 +1,38 @@
---
id: "9db40467-a826-4dac-a679-e7934cbdda81"
number: 98
title: "Profil cloud provider catalogue (ex: zai) : modèle choisi écrasé par glm-5.2 au spawn"
status: "closed"
priority: "high"
sprint: null
links: []
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
createdAt: 1784966899811
updatedAt: 1784983186248
version: 5
---
## Symptôme
Dans le wizard de création de profil AI OpenCode (first-run), quand l'utilisateur choisit un cloud provider du catalogue (ex: **zai / ZAI Code**) puis sélectionne un modèle spécifique, **après sauvegarde l'agent tourne en `glm-5.2`** quel que soit le modèle choisi.
## Périmètre
Bug **backend** (couche application/infrastructure du spawn OpenCode). Suite logique et distincte du ticket #97 (qui réglait l'exclusion mutuelle `opencode` vs `opencodeProvider` — désormais fixée).
## Cause racine (diagnostiquée, à confirmer par Architect)
- La persistance est **correcte** : `profiles.json` conserve bien `opencodeProvider.model` = modèle choisi. `glm-5.2` est **absent de tout le code IdeA** ; c'est le **fallback interne de la CLI OpenCode** quand elle ne sait pas résoudre le modèle demandé.
- Le modèle est perdu **au spawn** :
1. IdeA écrit `opencode.json` avec `model: "zai/<choix>"` mais **sans bloc `models`** pour les providers catalogue connus (`crates/application/src/agent/lifecycle.rs:2796-2821`, `crates/infrastructure/src/assistant/mod.rs:447-466`). Elle suppose OpenCode built-in.
2. Or `zai` n'est connu d'OpenCode **que** via son cache models.dev, et IdeA **isole `XDG_CACHE_HOME` vide** au spawn (`lifecycle.rs:2386,2405` ; `assistant/mod.rs:272,282`). → OpenCode ne résout ni `zai` ni le modèle → fallback silencieux `glm-5.2`.
- Pourquoi ça marche en local (llama.cpp) et custom : ils émettent un bloc `models` (`assistant/mod.rs:371-379`, `:457-460`).
## Exemples impactés
Tout provider cloud **connu uniquement via le cache models.dev** (zai, et vraisemblablement tout provider non built-in dans OpenCode). Les providers hardcodés dans OpenCode (anthropic, openai, openrouter) fonctionnent malgré l'absence de bloc `models`.
## Lié à
- **#97** (relatesTo) : exclusion mutuelle provider — prérequis déjà livré.

128
.ideai/tickets/99/carnet.md Normal file
View File

@ -0,0 +1,128 @@
---
issueRef: "#99"
version: 1
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784987973464
---
# Carnet #99 — Cadrage (prêt pour le cycle)
> Cadrage établi par Main après investigation code + **vérification empirique des binaires
> installés** (`codex-cli 0.145.0`, `Claude Code 2.1.220`). Les faits CLI ci-dessous sont
> **capitalisés et vérifiés** ; ils conditionnent toute l'architecture. Architect doit valider
> les ports/VO et les questions ouvertes (§6) avant l'implémentation.
---
## 1. État des lieux vérifié — comparaison des 3 moteurs
| Moteur | Modèle contrôlé par IdeA ? | Mécanisme | Source |
|---|---|---|---|
| **OpenCode** | ✅ Oui | `model` écrit à la racine du `opencode.json` isolé (`OPENCODE_CONFIG`), lu par la session structured | `lifecycle.rs:2686-2689`, `2786-2788` |
| **Codex** | ❌ Non | aucun `--model`, `codex_config_toml` n'écrit pas `model` → défaut du binaire | `codex.rs:215-239`, `lifecycle.rs:2941-2955` |
| **Claude** | ❌ Non | aucun `--model`, `claude_settings_seed` n'écrit pas `model` → défaut du binaire | `claude.rs:273-284`, `infrastructure/permission/claude.rs:59` |
PTY et headless sont **deux processus OS séparés** pour un même agent
(`allow_structured_alongside_pty: true`, `lifecycle.rs:1512-1517`) ; ils partagent le même
`CODEX_HOME`/cwd mais **aucun état modèle** n'est synchronisé entre eux côté IdeA. Le `/model`
de la TUI ne sort jamais du process TUI (pass-through brut, `terminal/usecases.rs:140`).
## 2. Faits CLI vérifiés (capitalisation) — la clé qui débloque tout
### Codex 0.145.0 — `model` est une clé config.toml **documentée**
- L'exemple littéral du `--help` est `-c model="o3"`. `config.toml` est chargé depuis
`$CODEX_HOME/config.toml`.
- **Honorée par `codex` (TUI) ET `codex exec` (headless)** — même mécanisme de base
(`--ignore-user-config` confirme qu'il est chargé par défaut).
- Bonus : `codex exec` accepte aussi `-m, --model <MODEL>` (seconde porte d'injection).
- **→ Écrire `model = "..."` dans le `$CODEX_HOME/config.toml` isolé fixe le défaut pour les
DEUX canaux.**
### Claude 2.1.220 — `model` est gérable via settings + flag
- `--model <model>` fonctionne en interactif **et** `-p/--print` (headless).
- `--settings <file-or-json>` + la hiérarchie `./.claude/settings.local.json` (project-local)
supportent une clé `model`.
- **→ Écrire `"model": "..."` dans le `.claude/settings.local.json` du run dir fixe le défaut
pour les DEUX canaux** (cwd = run dir, lu par PTY et headless).
### Conclusion d'architecture
Les trois moteurs convergent vers **le même pattern** : écrire le modèle du profil dans le
**fichier de config isolé du run dir**. Le fichier partagé = point de vérité unique pour
headless + TUI au lancement. L'exigence produit « modèle par défaut = modèle headless = modèle
exposé TUI au lancement » est satisfaite **par construction**, sans sync à coder.
## 3. Points d'intégration IdeA précis (avec chemins)
| Moteur | Renderer à modifier | Fichier produit | Ownership | Isolation |
|---|---|---|---|---|
| **Codex** | `codex_config_toml` (`application/agent/lifecycle.rs:2941-2955`) | `$CODEX_HOME/config.toml` | `MergeToml` (préservé aux régénérations) | `CODEX_HOME={runDir}/.codex` déjà isolé (`lifecycle.rs:2361`) |
| **Claude** | `claude_settings_seed` (`infrastructure/src/permission/claude.rs:59`) | `{runDir}/.claude/settings.local.json` | `Replace` (régénéré du profil à chaque lancement) | cwd=run dir ; pas d'iso home mais **project-local override user** → le modèle IdeA gagne |
Champ existant à consommer ou déprécier : **`AgentProfile.model: Option<String>`**
(`domain/src/profile.rs:1008`) — actuellement code mort. Référence OpenCode à répliquer :
`OpenCodeProviderConfig { provider_id, model, api_key_ref }` (`domain/src/profile.rs:372-391`)
+ `SaveOpenCodeProviderProfile` (`application/agent/usecases.rs`).
## 4. Architecture proposée — découpage en lots
- **A. Domaine** — Nouveaux VO miroir d'`OpenCodeProviderConfig` :
`CodexProviderConfig { provider, model, api_key_ref }` et `ClaudeProviderConfig { provider,
model, api_key_ref }`. Rendre les backends mutuellement exclusifs par moteur (cf.
`opencode_backend_is_consistent`, `domain/src/profile.rs:363-364,1224`). **Décision
Architect** : nouveau VO vs réutiliser `AgentProfile.model` (voir §6).
- **B. Renderers de config** — `codex_config_toml` écrit `model` (+ `model_provider` si requis
par la sémantique codex) ; `claude_settings_seed` écrit la clé `model`.
- **C. Use cases / persistance** — `SaveCodexProviderProfile` / `SaveClaudeProviderProfile`
miroirs de `SaveOpenCodeProviderProfile`, même SecretRef pour la clé scellée.
- **D. UI / wizard** — duplication de profils Codex/Claude comme OpenCode (catalogue providers,
sélection modèle, clé scellée). Réutiliser `provider_catalogue` + picker existants.
- **E. QA** — assert headless + TUI utilisent le modèle du profil (voir §5).
## 5. Invariants (à respecter, à tester)
- **Source de vérité unique** : le modèle vient du profil AI, écrit dans le fichier de config
isolé du run dir, lu par headless **et** TUI au lancement.
- **Isolation préservée** : Codex via `CODEX_HOME` (rien ne change) ; Claude via project-local
override (rien ne change). Pas d'accès au home global pour le modèle.
- **`/model` en cours de session TUI ne corrompt pas le headless** (acceptable : ne concerne que
le process PTY en cours ; le prochain lancement réapplique le défaut du profil).
- **Non-régression OpenCode** : rendu `opencode.json` byte-identique (rien ne touche ce chemin).
- **Non-régression permissions** : `codex_config_toml` et `claude_settings_seed` continuent
d'écrire `mcp`/`sandbox`/`trust`/`approval` comme aujourd'hui (le `model` s'ajoute).
## 6. Questions ouvertes pour Architect (à trancher avant DevBackend)
1. **VO** : créer `CodexProviderConfig`/`ClaudeProviderConfig` (homogène à OpenCode) **ou**
consommer le champ existant `AgentProfile.model` (code mort) ? Recommandation Main : nouveaux
VO pour rester isomorphe à OpenCode (provider + clé scellée), déprécier `AgentProfile.model`.
2. **Codex `model_provider`** : la config codex distingue `model` et `model_provider`. Faut-il
exposer les deux au profil, ou `model` seul suffit (provider implicite) ?
3. **Claude** : clé `model` dans `settings.local.json` suffit, ou faut-il gérer aussi la clé
API Anthropic du profil (SecretRef) pour que le modèle soit réellement joignable ?
4. **Catalogue providers** : quels providers exposer pour Codex (lié OpenAI) et Claude
(Anthropic) ? Réutiliser `provider_catalogue.rs` ou catalogue dédié par moteur ?
5. **Sémantique TUI** : confirmer qu'aucune CLI n'écrase `config.toml`/`settings` au démarrage
(risque équivalent au « gate refresh » du ticket #98 côté OpenCode).
## 7. Périmètre QA — 2 couches (cf. méthodologie #98)
- **Couche 1 (unitaire pure)** : le rendu `codex_config_toml` contient la clé `model`
attendue ; `claude_settings_seed` contient `"model"` attendue ; non-régression
byte-identique du reste (mcp/sandbox/trust/approval) ; exclusion mutuelle des backends.
- **Couche 2 (intégration gated)** : spawn réel `codex exec` et `claude -p` sur un profil avec
modèle X → assert le modèle actif est X (pas le défaut binaire). Vérifier côté TUI aussi
(modèle exposé au lancement). **Gate §6.5** à lever.
## 8. Hors périmètre (exclu de ce ticket)
- Override **runtime** du modèle à l'appel (`idea_ask_agent` porterait un modèle) — autre lot.
- Providers distants SSH/WSL (ne concerne que le spawn local).
- Détail de la `provider_catalogue` au-delà de la réutilisation (fast-follow).
---
## Contexte de mise à jour
- Cadrage Main après vérification empirique CLI (codex 0.145.0, claude 2.1.220).
- Lié à #98 (relatesTo) — même famille « modèle au spawn ».
- Prochaine étape cycle : **Architect** (valide ports/VO + tranche §6) → **Git** (branche) →
**DevBackend** (lots A-C) + **DevFrontend** (lot D) → **QA** (2 couches) → **Git** (commit).

View File

@ -0,0 +1,53 @@
---
id: "45733f3f-5ef5-4f33-96e7-25afddcd1ce6"
number: 99
title: "Modèle par agent contrôlable pour Codex & Claude (headless + TUI) — égaler le pattern OpenCode"
status: "open"
priority: "high"
sprint: null
links: [{"target":"#98","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1784987973464
updatedAt: 1784987973464
version: 1
---
## Problème
Aujourd'hui IdeA **ne contrôle pas le modèle** pour les moteurs **Codex** et **Claude** :
le modèle utilisé par le headless inter-agent (`codex exec` / `claude -p`) **et** par la TUI
interactive est le **défaut du binaire CLI installé**, indépendamment du profil AI de l'agent.
- Aucun flag `--model` n'est passé au spawn (`codex.rs:215-239`, `claude.rs:273-284`).
- `codex_config_toml` (`lifecycle.rs:2941-2955`) **n'écrit jamais de clé `model`**.
- `AgentProfile.model` (`profile.rs:1008`) existe mais est du **code mort** (jamais consommé).
- Le `/model` tapé dans la TUI ne se propage **pas** au headless : ce sont deux processus OS
séparés (PTY vs `codex exec`/`claude -p`) avec des threads distincts. Le `CODEX_HOME`/cwd est
partagé mais rien n'écrit le modèle sur disque côté IdeA.
**Contraste** : pour **OpenCode**, le modèle du profil (`OpenCodeConfig.model` /
`OpenCodeProviderConfig.model`) **est** celui utilisé par le headless, via le `opencode.json`
isolé pointé par `OPENCODE_CONFIG` (`lifecycle.rs:2686-2689`). Un profil IdeA = un
(provider, modèle, clé scellée).
## Objectif produit
Qu'il existe un **modèle par défaut par agent**, défini dans le **profil AI**, qui soit :
1. utilisé par le **headless inter-agent** (`idea_ask_agent`) ;
2. **exposé par la TUI au lancement** (valeur initiale du modèle dans la CLI) ;
pour les **trois** moteurs — OpenCode (déjà OK), Codex et Claude (à égaler).
## Périmètre
- **Backend** : nouveau VO domaine miroir d'`OpenCodeProviderConfig`, renderers de config
écrivant le modèle, use cases de persistance.
- **UI** : duplication / création de profils Codex et Claude comme OpenCode (catalogue de
providers, sélection du modèle, clé scellée via SecretRef).
## Lié à
- **#98** (relatesTo) : même famille « contrôle du modèle au spawn » (côté OpenCode, résolveur
zai/glm-5.2). Indépendant mais cohérent.

View File

@ -1,3 +1,3 @@
{
"nextNumber": 93
"nextNumber": 104
}

View File

@ -945,21 +945,137 @@
"title": "Sur la notification de fin de tache backend, mettre plutot les deux agents en conversation",
"status": "open",
"priority": "medium",
"sprint": null,
"sprint": "5afd6780-0f76-40d7-a10f-32ee52469d74",
"assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641"
],
"updatedAt": 1784652150003
"updatedAt": 1784994105390
},
{
"issueRef": "#92",
"path": "92",
"title": "Configurer OpenCode avec un provider Opencode",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784993956910
},
{
"issueRef": "#93",
"path": "93",
"title": "Agent Context : réponse finale non capturée, fragment d'appel d'outil brut renvoyé via idea_ask_agent",
"status": "closed",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784934179780
},
{
"issueRef": "#94",
"path": "94",
"title": "Permissions OpenCode ignorées : opencode.json code en dur bash/edit=ask au lieu de projeter EffectivePermissions",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784823278030
},
{
"issueRef": "#95",
"path": "95",
"title": "Rendez-vous idea_ask_agent : timeout MCP OpenCode trop court (15s), le demandeur OpenCode ne reçoit jamais la réponse",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784934174326
},
{
"issueRef": "#96",
"path": "96",
"title": "Les assistants de ticket (tous adaptateurs) n'ont aucune résolution EffectivePermissions — permission toujours native/absente",
"status": "open",
"priority": "low",
"sprint": "883534aa-7fc1-4d83-a9c0-17cac4a4eea5",
"assignedAgentIds": [],
"updatedAt": 1784994119004
},
{
"issueRef": "#97",
"path": "97",
"title": "Duplication de provider lors de la création de profil AI - llamacpp ET provider cloud inclus simultanément",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784915626417
},
{
"issueRef": "#98",
"path": "98",
"title": "Profil cloud provider catalogue (ex: zai) : modèle choisi écrasé par glm-5.2 au spawn",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784983186248
},
{
"issueRef": "#99",
"path": "99",
"title": "Modèle par agent contrôlable pour Codex & Claude (headless + TUI) — égaler le pattern OpenCode",
"status": "open",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784788822167
"updatedAt": 1784987973464
},
{
"issueRef": "#100",
"path": "100",
"title": "[Bug] Problème sur le scroll des agents OpenCode",
"status": "open",
"priority": "high",
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
"assignedAgentIds": [],
"updatedAt": 1784993984569
},
{
"issueRef": "#101",
"path": "101",
"title": "[Bug] Soucis de retour de notification sur les taches backend",
"status": "closed",
"priority": "critical",
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
"assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641"
],
"updatedAt": 1785011116365
},
{
"issueRef": "#102",
"path": "102",
"title": "[Bug] Devoir resize les cellule pour afficher la TUI d'un agent",
"status": "open",
"priority": "medium",
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
"assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641"
],
"updatedAt": 1784993980505
},
{
"issueRef": "#103",
"path": "103",
"title": "Exposer et piloter la permission réseau des agents/commandes dans IdeA",
"status": "qa",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
"a6ced819-b893-4213-b003-9e9dc79b9641"
],
"updatedAt": 1785013507979
}
]
}

View File

@ -45,7 +45,7 @@ fn update_project_system_permissions_request_deserializes_allow_deny_ask() {
}
#[test]
fn resolved_agent_system_permissions_dto_carries_runtime_lock_and_read_only_control() {
fn resolved_agent_system_permissions_dto_separates_wanted_control_from_runtime_control() {
let dto = ResolvedAgentSystemPermissionsDto(ResolvedAgentSystemPermissions {
wanted: Some(NetworkPolicy::Allow),
effective: NetworkPolicy::Deny,
@ -55,8 +55,12 @@ fn resolved_agent_system_permissions_dto_carries_runtime_lock_and_read_only_cont
reason: Some("not inspectable".to_owned()),
},
control: SystemPermissionControl {
mode: SystemPermissionControlMode::Editable,
reason: None,
},
runtime_control: SystemPermissionControl {
mode: SystemPermissionControlMode::ReadOnly,
reason: Some("not editable".to_owned()),
reason: Some("runtime not editable".to_owned()),
},
});
@ -65,5 +69,6 @@ fn resolved_agent_system_permissions_dto_carries_runtime_lock_and_read_only_cont
assert_eq!(value["wanted"], "allow");
assert_eq!(value["effective"], "deny");
assert_eq!(value["runtimeLock"]["state"], "locked");
assert_eq!(value["control"]["mode"], "readOnly");
assert_eq!(value["control"]["mode"], "editable");
assert_eq!(value["runtimeControl"]["mode"], "readOnly");
}

View File

@ -18,17 +18,17 @@ use domain::ports::{
AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus,
FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PermissionStore, PreparedContext,
ProfileStore, ProjectStore, PtyPort, RemotePath, SecretStore, SessionPlan, SkillStore,
SpawnSpec, StoreError,
SpawnSpec, StoreError, StructuredProviderLaunchPolicy, SystemPermissionStore,
};
use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter};
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
use domain::{
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId,
PermissionProjector, ProfileId, Project, ProjectPath, ProjectedFile, ProjectionContext,
ProjectorKey, ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, Skill,
TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NetworkPolicy,
NodeId, PermissionProjector, Posture, ProfileId, Project, ProjectPath, ProjectedFile,
ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize, SessionId, SessionKind,
SessionStatus, Skill, TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
};
use domain::live_state::WorkStatus;
@ -1092,6 +1092,44 @@ fn select_projector_key(profile: &AgentProfile) -> Option<ProjectorKey> {
None
}
fn codex_structured_sandbox_mode(permissions: Option<&EffectivePermissions>) -> &'static str {
match permissions.map(EffectivePermissions::fallback) {
Some(Posture::Deny) => "read-only",
Some(Posture::Ask | Posture::Allow) | None => "workspace-write",
}
}
fn codex_network_access(network: Option<NetworkPolicy>) -> bool {
matches!(network, Some(NetworkPolicy::Allow))
}
fn build_structured_launch_policy(
profile: &AgentProfile,
permissions: Option<&EffectivePermissions>,
network: Option<NetworkPolicy>,
project_root: &ProjectPath,
) -> Option<StructuredProviderLaunchPolicy> {
if !matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) {
return None;
}
if select_projector_key(profile) != Some(ProjectorKey::Codex) {
return None;
}
let sandbox_mode = codex_structured_sandbox_mode(permissions).to_owned();
let writable_roots = if sandbox_mode == "workspace-write" && !project_root.as_str().is_empty() {
vec![project_root.as_str().to_owned()]
} else {
Vec::new()
};
Some(StructuredProviderLaunchPolicy::Codex {
sandbox_mode,
writable_roots,
network_access: codex_network_access(network),
})
}
/// Launches an agent: resolve profile + context, prepare the invocation, apply
/// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI.
///
@ -1123,6 +1161,9 @@ pub struct LaunchAgent {
/// historical hardcoded CLI permission seeds. When present and a project or
/// agent policy is configured, the resolved policy is projected to the CLI.
permissions: Option<Arc<dyn PermissionStore>>,
/// Optional system permission store. Network policy is separate from
/// filesystem/bash permissions, but it must still be projected into launch env.
system_permissions: Option<Arc<dyn SystemPermissionStore>>,
/// Fabrique des sessions **structurées** (IA, §17). Injectée au câblage
/// (composition root) via [`Self::with_structured`]. `None` ⇒ le routage §17.4
/// est désactivé et **tout** profil suit le chemin PTY historique (mode legacy /
@ -1200,6 +1241,7 @@ impl LaunchAgent {
recall,
embedder_suggestion,
permissions: None,
system_permissions: None,
session_factory: None,
structured: None,
handoffs: None,
@ -1268,6 +1310,17 @@ impl LaunchAgent {
self
}
/// Injects the project system permission store used to resolve network policy
/// at launch time.
#[must_use]
pub fn with_system_permission_store(
mut self,
permissions: Arc<dyn SystemPermissionStore>,
) -> Self {
self.system_permissions = Some(permissions);
self
}
/// Branche le provider de **store de sessions provider (lot P8b)** sur ce launcher :
/// après un lancement structuré exposant un id de session moteur, range
/// `(pair_conversation_id, provider_key) → engine_session_id` dans `providers.json`
@ -1653,6 +1706,9 @@ impl LaunchAgent {
let effective_permissions = self
.resolve_effective_permissions(&input.project, agent.id)
.await?;
let network_permission = self
.resolve_launch_network_permission(&input.project, agent.id)
.await?;
// 3b. (Permission projection moved to step 5c, after the convention file and
// the MCP config, so both the structured and PTY paths inherit it — see
@ -1752,6 +1808,7 @@ impl LaunchAgent {
&run_dir,
&input.project.root,
effective_permissions.as_ref(),
network_permission,
&mut spec,
)
.await?;
@ -1771,6 +1828,12 @@ impl LaunchAgent {
run_dir: run_dir.as_str(),
},
);
let structured_policy = build_structured_launch_policy(
&profile,
effective_permissions.as_ref(),
network_permission,
&input.project.root,
);
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
// L'intention est explicite sur le launcher : les cellules humaines peuvent
@ -1818,6 +1881,7 @@ impl LaunchAgent {
size,
&spec.env,
spec.sandbox.as_ref(),
structured_policy.as_ref(),
)
.await;
}
@ -1895,6 +1959,7 @@ impl LaunchAgent {
size: PtySize,
env: &[(String, String)],
sandbox: Option<&SandboxPlan>,
structured_policy: Option<&StructuredProviderLaunchPolicy>,
) -> 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.
@ -1907,6 +1972,7 @@ impl LaunchAgent {
Some(&agent.id.to_string()),
env,
sandbox,
structured_policy,
)
.await
.map_err(|e| AppError::Process(e.to_string()))?;
@ -2114,9 +2180,9 @@ impl LaunchAgent {
/// - [`ProjectedFile::MergeToml`] ⇒ merge only the **managed** tables/keys into
/// any existing file (via the shared TOML helpers), preserving everything else.
///
/// No-op when: no registry is wired, the profile has no matching projector, or
/// the resolved permissions are `None` (the projector returns an empty
/// projection — native prompting preserved).
/// No-op when: no registry is wired, or the profile has no matching projector.
/// `permissions == None` only means no filesystem/bash policy is projected; the
/// projector may still apply orthogonal system policy such as network env.
///
/// # Errors
/// [`AppError::FileSystem`] if a plan file cannot be written.
@ -2126,6 +2192,7 @@ impl LaunchAgent {
run_dir: &ProjectPath,
project_root: &ProjectPath,
permissions: Option<&EffectivePermissions>,
network: Option<NetworkPolicy>,
spec: &mut SpawnSpec,
) -> Result<(), AppError> {
let Some(registry) = &self.projectors else {
@ -2142,7 +2209,7 @@ impl LaunchAgent {
project_root: project_root.as_str(),
run_dir: run_dir.as_str(),
};
let projection = projector.project(permissions, &ctx);
let projection = projector.project(permissions, network, &ctx);
for file in &projection.files {
match file {
@ -2172,9 +2239,12 @@ impl LaunchAgent {
}
// Fold the plan's launch args/env into the spec, before the structured/PTY
// split so both inherit them.
// split so both inherit them. Env is upserted so stale runtime/parent values
// cannot shadow the project policy for keys such as Codex network.
spec.args.extend(projection.args.iter().cloned());
spec.env.extend(projection.env.iter().cloned());
for (key, value) in projection.env.iter() {
upsert_env(&mut spec.env, key, value);
}
Ok(())
}
@ -2208,6 +2278,18 @@ impl LaunchAgent {
Ok(doc.resolve_for(agent_id))
}
async fn resolve_launch_network_permission(
&self,
project: &Project,
agent_id: AgentId,
) -> Result<Option<NetworkPolicy>, AppError> {
let Some(store) = &self.system_permissions else {
return Ok(None);
};
let doc = store.load_system_permissions(project).await?;
Ok(doc.wanted_network_for(agent_id))
}
/// Applies the context-injection plan that must happen *before* spawn:
/// materialising a `conventionFile` context (write the `.md` to `<cwd>/target`)
/// or attaching the on-disk context path to an environment variable. `Args` is
@ -2877,6 +2959,14 @@ fn join(base: &ProjectPath, rel: &str) -> String {
format!("{b}/{rel}")
}
fn upsert_env(env: &mut Vec<(String, String)>, key: &str, value: &str) {
if let Some((_, existing)) = env.iter_mut().find(|(candidate, _)| candidate == key) {
*existing = value.to_owned();
} else {
env.push((key.to_owned(), value.to_owned()));
}
}
fn parent_rel(rel: &str) -> Option<&str> {
rel.rsplit_once(['/', '\\']).map(|(parent, _)| parent)
}

View File

@ -2,11 +2,15 @@
use std::sync::Arc;
use domain::ports::{AgentSessionFactory, SessionPlan, StructuredSessionEnvironmentPreparer};
use domain::ports::{
AgentSessionFactory, SessionPlan, StructuredProviderLaunchPolicy,
StructuredSessionEnvironmentPreparer, SystemPermissionStore,
};
use domain::profile::StructuredAdapter;
use domain::AgentProfile;
use domain::{
AgentToolPolicy, AgentToolPolicyStore, AssistantContextProvider, DomainEvent, EventBus,
IssueRef, IssueStore, ProfileId, ProfileStore, Project, SessionId,
IssueRef, IssueStore, NetworkPolicy, ProfileId, ProfileStore, Project, SessionId,
};
use crate::terminal::StructuredSessions;
@ -22,6 +26,7 @@ pub struct OpenTicketAssistant {
structured: Arc<StructuredSessions>,
policies: Arc<dyn AgentToolPolicyStore>,
events: Arc<dyn EventBus>,
system_permissions: Option<Arc<dyn SystemPermissionStore>>,
}
/// Input for [`OpenTicketAssistant`].
@ -68,9 +73,17 @@ impl OpenTicketAssistant {
structured,
policies,
events,
system_permissions: None,
}
}
/// Wires the project system permission store used for assistant launches.
#[must_use]
pub fn with_system_permission_store(mut self, store: Arc<dyn SystemPermissionStore>) -> Self {
self.system_permissions = Some(store);
self
}
/// Executes the open flow.
///
/// Reopening an already-live assistant for the same ticket replaces the old
@ -136,6 +149,9 @@ impl OpenTicketAssistant {
return Err(AppError::from(err));
}
};
let structured_policy = self
.build_ticket_assistant_structured_policy(&input.project, &profile, &environment)
.await?;
let session = self
.factory
.start(
@ -146,6 +162,9 @@ impl OpenTicketAssistant {
Some(&requester),
&environment.env,
None,
structured_policy
.as_ref()
.or(environment.structured_policy.as_ref()),
)
.await
.map_err(|err| {
@ -180,6 +199,38 @@ impl OpenTicketAssistant {
issue_ref: input.issue_ref,
})
}
async fn build_ticket_assistant_structured_policy(
&self,
project: &Project,
profile: &AgentProfile,
environment: &domain::ports::StructuredSessionEnvironment,
) -> Result<Option<StructuredProviderLaunchPolicy>, AppError> {
if profile.structured_adapter != Some(StructuredAdapter::Codex) {
return Ok(environment.structured_policy.clone());
}
let network = self.resolve_project_default_network(project).await?;
Ok(Some(StructuredProviderLaunchPolicy::Codex {
sandbox_mode: "workspace-write".to_owned(),
writable_roots: vec![project.root.as_str().to_owned()],
network_access: codex_network_access(network),
}))
}
async fn resolve_project_default_network(
&self,
project: &Project,
) -> Result<Option<NetworkPolicy>, AppError> {
let Some(store) = &self.system_permissions else {
return Ok(None);
};
let doc = store.load_system_permissions(project).await?;
Ok(doc.project_default.and_then(|set| set.network))
}
}
fn codex_network_access(network: Option<NetworkPolicy>) -> bool {
matches!(network, Some(NetworkPolicy::Allow))
}
fn ticket_assistant_requester(project: &Project, issue_ref: IssueRef) -> String {

View File

@ -32,7 +32,7 @@ use domain::ports::{
ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError,
IdGenerator, MemoryError, MemoryQuery, MemoryRecall, OutputStream, PermissionStore,
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyStream,
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, SystemPermissionStore,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig,
@ -41,8 +41,8 @@ use domain::profile::{
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::{MemoryIndexEntry, MemorySlug, MemoryType};
use domain::{PermissionSet, ProjectPermissions};
use domain::{MemoryIndexEntry, MemorySlug, MemoryType, NetworkPolicy};
use domain::{PermissionSet, ProjectPermissions, ProjectSystemPermissions, SystemPermissionSet};
use domain::{PtySize, SessionId, SkillId, SkillRef};
use uuid::Uuid;
@ -301,6 +301,7 @@ fn mem_entry(slug: &str, title: &str, hook: &str, kind: MemoryType) -> MemoryInd
struct FakeRuntime {
trace: Trace,
plan: Option<ContextInjectionPlan>,
env: Vec<(String, String)>,
/// The last [`SessionPlan`] handed to `prepare_invocation`, captured so tests
/// can assert the launch resolved the right Assign/Resume/None intention (T4).
last_session: Arc<Mutex<Option<SessionPlan>>>,
@ -311,9 +312,14 @@ impl FakeRuntime {
Self {
trace,
plan,
env: Vec::new(),
last_session: Arc::new(Mutex::new(None)),
}
}
fn with_env(mut self, env: Vec<(String, String)>) -> Self {
self.env = env;
self
}
/// Shared handle to inspect the captured session plan after a launch.
fn session_probe(&self) -> Arc<Mutex<Option<SessionPlan>>> {
Arc::clone(&self.last_session)
@ -338,7 +344,7 @@ impl AgentRuntime for FakeRuntime {
command: profile.command.clone(),
args: profile.args.clone(),
cwd: cwd.clone(),
env: Vec::new(),
env: self.env.clone(),
context_plan: self.plan.clone(),
sandbox: None,
})
@ -598,6 +604,8 @@ impl AgentSession for FakeSession {
struct FakeStructuredFactory {
trace: Trace,
starts: Arc<Mutex<Vec<ProfileId>>>,
envs: Arc<Mutex<Vec<Vec<(String, String)>>>>,
policies: Arc<Mutex<Vec<Option<domain::ports::StructuredProviderLaunchPolicy>>>>,
next_session: SessionId,
}
@ -606,6 +614,8 @@ impl FakeStructuredFactory {
Self {
trace,
starts: Arc::new(Mutex::new(Vec::new())),
envs: Arc::new(Mutex::new(Vec::new())),
policies: Arc::new(Mutex::new(Vec::new())),
next_session,
}
}
@ -613,6 +623,14 @@ impl FakeStructuredFactory {
fn starts(&self) -> Vec<ProfileId> {
self.starts.lock().unwrap().clone()
}
fn envs(&self) -> Vec<Vec<(String, String)>> {
self.envs.lock().unwrap().clone()
}
fn policies(&self) -> Vec<Option<domain::ports::StructuredProviderLaunchPolicy>> {
self.policies.lock().unwrap().clone()
}
}
#[async_trait]
@ -630,12 +648,18 @@ impl AgentSessionFactory for FakeStructuredFactory {
_requester: Option<&str>,
_env: &[(String, String)],
_sandbox: Option<&domain::sandbox::SandboxPlan>,
structured_policy: Option<&domain::ports::StructuredProviderLaunchPolicy>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
self.trace
.lock()
.unwrap()
.push("structured.start".to_owned());
self.starts.lock().unwrap().push(profile.id);
self.envs.lock().unwrap().push(_env.to_vec());
self.policies
.lock()
.unwrap()
.push(structured_policy.cloned());
Ok(Arc::new(FakeSession {
id: self.next_session,
conversation_id: Some("engine-session-1".to_owned()),
@ -2994,6 +3018,26 @@ impl PermissionStore for FakePermissionStore {
}
}
struct FakeSystemPermissionStore(ProjectSystemPermissions);
#[async_trait]
impl SystemPermissionStore for FakeSystemPermissionStore {
async fn load_system_permissions(
&self,
_project: &Project,
) -> Result<ProjectSystemPermissions, StoreError> {
Ok(self.0.clone())
}
async fn save_system_permissions(
&self,
_project: &Project,
_permissions: &ProjectSystemPermissions,
) -> Result<(), StoreError> {
Ok(())
}
}
/// Faithful Claude projector double: emits a single owned `Replace` settings file
/// whose `defaultMode` mirrors the real posture→mode mapping (Allow→bypass,
/// Ask→acceptEdits, Deny→plan), and embeds the project root verbatim. `eff == None`
@ -3007,6 +3051,7 @@ impl PermissionProjector for FakeClaudeProjector {
fn project(
&self,
eff: Option<&EffectivePermissions>,
_network: Option<NetworkPolicy>,
ctx: &ProjectionContext,
) -> PermissionProjection {
let Some(eff) = eff else {
@ -3038,8 +3083,9 @@ impl PermissionProjector for FakeClaudeProjector {
/// Faithful Codex projector double: emits a co-owned `MergeToml` over the two
/// managed keys + the matching `--sandbox`/`--ask-for-approval` args, both derived
/// from the posture exactly like the real projector. Workspace-write postures also
/// add the project root as a writable directory (`--add-dir`). `eff == None` ⇒
/// empty.
/// add the project root as a writable directory (`--add-dir`). `eff == None` ⇒ no
/// filesystem/bash projection, but network env remains projected because system
/// permissions are orthogonal.
struct FakeCodexProjector;
impl FakeCodexProjector {
@ -3059,13 +3105,37 @@ impl PermissionProjector for FakeCodexProjector {
fn project(
&self,
eff: Option<&EffectivePermissions>,
network: Option<NetworkPolicy>,
ctx: &ProjectionContext,
) -> PermissionProjection {
let env = {
let disabled = match network {
Some(NetworkPolicy::Allow) => "0",
Some(NetworkPolicy::Deny | NetworkPolicy::Ask) | None => "1",
};
vec![(
"CODEX_SANDBOX_NETWORK_DISABLED".to_owned(),
disabled.to_owned(),
)]
};
let network_access = matches!(network, Some(NetworkPolicy::Allow));
let network_file = ProjectedFile::MergeToml {
rel_path: ".codex/config.toml".to_owned(),
managed_tables: vec!["sandbox_workspace_write".to_owned()],
managed_keys: Vec::new(),
contents: format!("[sandbox_workspace_write]\nnetwork_access = {network_access}\n"),
};
let Some(eff) = eff else {
return PermissionProjection::empty();
return PermissionProjection {
files: vec![network_file],
env,
..PermissionProjection::empty()
};
};
let (sandbox, approval) = Self::modes(eff.fallback());
let contents = format!("sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n");
let contents = format!(
"sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n\n[sandbox_workspace_write]\nnetwork_access = {network_access}\n"
);
let mut args = vec![
"--sandbox".to_owned(),
sandbox.to_owned(),
@ -3079,12 +3149,12 @@ impl PermissionProjector for FakeCodexProjector {
PermissionProjection {
files: vec![ProjectedFile::MergeToml {
rel_path: ".codex/config.toml".to_owned(),
managed_tables: Vec::new(),
managed_tables: vec!["sandbox_workspace_write".to_owned()],
managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
contents,
}],
args,
env: Vec::new(),
env,
}
}
fn owned_replace_paths(&self) -> Vec<String> {
@ -3131,6 +3201,16 @@ fn launch_with_projection(
plan: Option<ContextInjectionPlan>,
registry: Option<Arc<PermissionProjectorRegistry>>,
perm_doc: Option<ProjectPermissions>,
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
launch_with_projection_and_env(profile, plan, registry, perm_doc, Vec::new())
}
fn launch_with_projection_and_env(
profile: AgentProfile,
plan: Option<ContextInjectionPlan>,
registry: Option<Arc<PermissionProjectorRegistry>>,
perm_doc: Option<ProjectPermissions>,
env: Vec<(String, String)>,
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
@ -3142,7 +3222,7 @@ fn launch_with_projection(
let mut launch = LaunchAgent::new(
Arc::new(contexts),
Arc::new(profiles),
Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)),
Arc::new(FakeRuntime::new(Arc::clone(&tr), plan).with_env(env)),
Arc::new(fs.clone()),
Arc::new(pty.clone()),
Arc::new(FakeSkills::default()),
@ -3270,6 +3350,221 @@ async fn projection_falls_back_to_codex_from_structured_adapter() {
);
}
#[tokio::test]
async fn codex_projection_folds_network_env_into_pty_spawn() {
for (policy, disabled) in [
(Some(NetworkPolicy::Allow), "0"),
(Some(NetworkPolicy::Deny), "1"),
(Some(NetworkPolicy::Ask), "1"),
(None, "1"),
] {
let profile = codex_profile().with_structured_adapter(StructuredAdapter::Codex);
let (mut launch, agent, _fs, pty, _s) = launch_with_projection(
profile,
Some(ContextInjectionPlan::File {
target: "AGENTS.md".to_owned(),
}),
Some(full_registry()),
Some(perm_doc(Posture::Allow)),
);
let system_doc =
ProjectSystemPermissions::new(Some(SystemPermissionSet::new(policy)), Vec::new());
launch =
launch.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
assert_eq!(
pty.spawns()[0]
.env
.iter()
.find(|(key, _)| key == "CODEX_SANDBOX_NETWORK_DISABLED")
.map(|(_, value)| value.as_str()),
Some(disabled),
"network policy {policy:?} must be projected into the final child env"
);
}
}
#[tokio::test]
async fn codex_network_allow_overrides_stale_disabled_env_in_pty_spawn() {
let profile = codex_profile().with_structured_adapter(StructuredAdapter::Codex);
let (mut launch, agent, _fs, pty, _s) = launch_with_projection_and_env(
profile,
Some(ContextInjectionPlan::File {
target: "AGENTS.md".to_owned(),
}),
Some(full_registry()),
Some(perm_doc(Posture::Allow)),
vec![("CODEX_SANDBOX_NETWORK_DISABLED".to_owned(), "1".to_owned())],
);
let system_doc = ProjectSystemPermissions::new(
Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
Vec::new(),
);
launch = launch.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
let spawns = pty.spawns();
let values = spawns[0]
.env
.iter()
.filter(|(key, _)| key == "CODEX_SANDBOX_NETWORK_DISABLED")
.map(|(_, value)| value.as_str())
.collect::<Vec<_>>();
assert_eq!(
values,
vec!["0"],
"network Allow must replace stale disabled env instead of appending a duplicate"
);
}
#[tokio::test]
async fn codex_network_env_is_projected_without_filesystem_permission_policy() {
let profile = codex_profile().with_projector(ProjectorKey::Codex);
let (mut launch, agent, fs, pty, _s) = launch_with_projection(
profile,
Some(ContextInjectionPlan::File {
target: "AGENTS.md".to_owned(),
}),
Some(full_registry()),
None,
);
let system_doc = ProjectSystemPermissions::new(
Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
Vec::new(),
);
launch = launch.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
assert!(
fs.writes_ending_with(CODEX_CONFIG_REL)
.iter()
.any(|(_, contents)| std::str::from_utf8(contents)
.unwrap()
.contains("[sandbox_workspace_write]\nnetwork_access = true")),
"network policy must still update Codex config without filesystem/bash policy"
);
assert!(
!pty.spawns()[0].args.contains(&"--sandbox".to_owned()),
"no filesystem/bash policy means no Codex sandbox args are folded"
);
assert_eq!(
pty.spawns()[0]
.env
.iter()
.find(|(key, _)| key == "CODEX_SANDBOX_NETWORK_DISABLED")
.map(|(_, value)| value.as_str()),
Some("0"),
"network Allow must still reach the final child env"
);
}
#[tokio::test]
async fn codex_projection_forwards_network_env_to_structured_session_factory() {
let profile = codex_profile()
.with_projector(ProjectorKey::Codex)
.with_structured_adapter(StructuredAdapter::Codex);
let (mut launch, agent, _fs, pty, _s) = launch_with_projection(
profile,
Some(ContextInjectionPlan::File {
target: "AGENTS.md".to_owned(),
}),
Some(full_registry()),
None,
);
let system_doc = ProjectSystemPermissions::new(
Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
Vec::new(),
);
launch = launch.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));
let factory = FakeStructuredFactory::new(trace(), sid(888));
let structured = Arc::new(StructuredSessions::new());
launch = launch
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
.with_structured(Arc::new(factory.clone()), Arc::clone(&structured));
let out = launch
.execute(launch_input(agent.id))
.await
.expect("structured launch");
assert!(
pty.spawns().is_empty(),
"structured route must not spawn PTY"
);
assert!(
out.structured.is_some(),
"structured launch output is populated"
);
assert_eq!(factory.envs().len(), 1, "exactly one structured launch");
assert_eq!(
factory.envs()[0]
.iter()
.find(|(key, _)| key == "CODEX_SANDBOX_NETWORK_DISABLED")
.map(|(_, value)| value.as_str()),
Some("0"),
"network Allow must reach the structured session factory env"
);
}
#[tokio::test]
async fn codex_structured_policy_carries_exec_safe_sandbox_roots_and_network() {
let profile = codex_profile()
.with_projector(ProjectorKey::Codex)
.with_structured_adapter(StructuredAdapter::Codex);
let (mut launch, agent, _fs, pty, _s) = launch_with_projection(
profile,
Some(ContextInjectionPlan::File {
target: "AGENTS.md".to_owned(),
}),
Some(full_registry()),
Some(perm_doc(Posture::Deny)),
);
let system_doc = ProjectSystemPermissions::new(
Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
Vec::new(),
);
launch = launch.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));
let factory = FakeStructuredFactory::new(trace(), sid(889));
let structured = Arc::new(StructuredSessions::new());
launch = launch
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
.with_structured(Arc::new(factory.clone()), Arc::clone(&structured));
launch
.execute(launch_input(agent.id))
.await
.expect("structured launch");
assert!(
pty.spawns().is_empty(),
"structured route must not spawn PTY"
);
assert_eq!(
factory.policies(),
vec![Some(domain::ports::StructuredProviderLaunchPolicy::Codex {
sandbox_mode: "read-only".to_owned(),
writable_roots: Vec::new(),
network_access: true,
})],
"write-denied Codex structured policy must be exec-safe and omit writable roots"
);
}
/// (1d) A non-projectable profile (no projector, no CLAUDE.md, no Codex signal) ⇒
/// no projection at all, even with a full registry and a posed policy.
#[tokio::test]

View File

@ -45,8 +45,8 @@ use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::{
LayoutId, LayoutNode, LayoutTree, LeafCell, MemoryIndexEntry, NodeId, PermissionSet, Posture,
ProjectPermissions, PtySize, SessionId, SessionKind, SkillId,
LayoutId, LayoutNode, LayoutTree, LeafCell, MemoryIndexEntry, NetworkPolicy, NodeId,
PermissionSet, Posture, ProjectPermissions, PtySize, SessionId, SessionKind, SkillId,
};
use uuid::Uuid;
@ -846,6 +846,7 @@ impl PermissionProjector for FakeClaudeProjector {
fn project(
&self,
eff: Option<&EffectivePermissions>,
_network: Option<NetworkPolicy>,
ctx: &ProjectionContext,
) -> PermissionProjection {
if eff.is_none() {
@ -880,6 +881,7 @@ impl PermissionProjector for FakeCodexProjector {
fn project(
&self,
eff: Option<&EffectivePermissions>,
_network: Option<NetworkPolicy>,
_ctx: &ProjectionContext,
) -> PermissionProjection {
if eff.is_none() {

View File

@ -1508,6 +1508,7 @@ impl AgentSessionFactory for CompletionFactory {
_requester: Option<&str>,
_env: &[(String, String)],
_sandbox: Option<&domain::sandbox::SandboxPlan>,
_structured_policy: Option<&domain::ports::StructuredProviderLaunchPolicy>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
let id = {
let mut n = self.next_id.lock().unwrap();
@ -4125,6 +4126,7 @@ impl AgentSessionFactory for CountingFactory {
_requester: Option<&str>,
_env: &[(String, String)],
_sandbox: Option<&domain::sandbox::SandboxPlan>,
_structured_policy: Option<&domain::ports::StructuredProviderLaunchPolicy>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
self.starts.fetch_add(1, Ordering::SeqCst);
let id = {

View File

@ -531,6 +531,7 @@ impl AgentSessionFactory for FakeFactory {
_requester: Option<&str>,
_env: &[(String, String)],
_sandbox: Option<&domain::sandbox::SandboxPlan>,
_structured_policy: Option<&domain::ports::StructuredProviderLaunchPolicy>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
self.starts
.lock()
@ -1158,7 +1159,16 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
let cwd = ProjectPath::new(ROOT).unwrap();
let session = f
.factory
.start(&profile, &ctx, &cwd, &SessionPlan::None, None, &[], None)
.start(
&profile,
&ctx,
&cwd,
&SessionPlan::None,
None,
&[],
None,
None,
)
.await
.expect("seed structured session");
f.structured

View File

@ -54,6 +54,19 @@ impl RuntimePermissionProbe for LockedProbe {
}
}
struct UnobservedProbe;
#[async_trait]
impl RuntimePermissionProbe for UnobservedProbe {
async fn probe_runtime_permissions(
&self,
_project: &Project,
_agent_id: AgentId,
) -> Result<RuntimePermissionSnapshot, RuntimeError> {
Ok(RuntimePermissionSnapshot::unobserved())
}
}
fn project() -> Project {
Project::new(
ProjectId::new_random(),
@ -125,7 +138,7 @@ async fn update_agent_system_permissions_adds_and_removes_sparse_override() {
}
#[tokio::test]
async fn resolve_agent_system_permissions_exposes_locked_runtime_read_only() {
async fn resolve_agent_system_permissions_exposes_locked_runtime_separately_from_wanted_control() {
let agent = AgentId::new_random();
let store = Arc::new(FakeSystemPermissionStore {
doc: Mutex::new(ProjectSystemPermissions::new(
@ -149,6 +162,43 @@ async fn resolve_agent_system_permissions_exposes_locked_runtime_read_only() {
assert_eq!(out.permissions.runtime_lock.state, RuntimeLockState::Locked);
assert_eq!(
out.permissions.control.mode,
SystemPermissionControlMode::Editable
);
assert_eq!(
out.permissions.runtime_control.mode,
SystemPermissionControlMode::ReadOnly
);
}
#[tokio::test]
async fn resolve_agent_system_permissions_unobserved_runtime_does_not_lock_form() {
let agent = AgentId::new_random();
let store = Arc::new(FakeSystemPermissionStore {
doc: Mutex::new(ProjectSystemPermissions::new(
Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
vec![],
)),
saves: Mutex::new(0),
});
let use_case = ResolveAgentSystemPermissions::new(store, Arc::new(UnobservedProbe));
let out = use_case
.execute(ResolveAgentSystemPermissionsInput {
project: project(),
agent_id: agent,
})
.await
.unwrap();
assert_eq!(out.permissions.wanted, Some(NetworkPolicy::Allow));
assert_eq!(out.permissions.effective, NetworkPolicy::Allow);
assert_eq!(out.permissions.runtime_lock.state, RuntimeLockState::None);
assert_eq!(
out.permissions.control.mode,
SystemPermissionControlMode::Editable
);
assert_eq!(
out.permissions.runtime_control.mode,
SystemPermissionControlMode::ReadOnly
);
}

View File

@ -9,15 +9,17 @@ use application::{
use async_trait::async_trait;
use domain::ports::{
AgentSession, AgentSessionError, AgentSessionFactory, ReplyStream, RuntimeError, SessionPlan,
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer,
StoreError, StructuredProviderLaunchPolicy, StructuredSessionEnvironment,
StructuredSessionEnvironmentPreparer, SystemPermissionStore,
};
use domain::profile::StructuredAdapter;
use domain::{
AgentProfile, AgentToolPolicy, AgentToolPolicyStore, AssistantContextError,
AssistantContextProvider, ContextInjection, DomainEvent, EventBus, EventStream, Issue,
IssueActor, IssueCarnet, IssueId, IssueListFilter, IssueNumber, IssuePriority, IssueRef,
IssueStatus, IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, PreparedContext,
ProfileId, ProfileStore, Project, ProjectId, ProjectPath, RemoteRef, SessionId, StoreError,
IssueStatus, IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, NetworkPolicy,
PreparedContext, ProfileId, ProfileStore, Project, ProjectId, ProjectPath,
ProjectSystemPermissions, RemoteRef, SessionId, SystemPermissionSet,
};
use uuid::Uuid;
@ -215,6 +217,7 @@ impl StructuredSessionEnvironmentPreparer for FakeEnvironmentPreparer {
"CODEX_HOME".to_owned(),
"/tmp/app-data/assistant/tickets/1/7/.codex".to_owned(),
)],
structured_policy: None,
})
}
}
@ -254,6 +257,7 @@ struct FakeFactory {
Option<String>,
Vec<(String, String)>,
Option<domain::SandboxPlan>,
Option<StructuredProviderLaunchPolicy>,
)>,
>,
shutdowns: Arc<Mutex<usize>>,
@ -274,6 +278,7 @@ impl AgentSessionFactory for FakeFactory {
requester: Option<&str>,
env: &[(String, String)],
sandbox: Option<&domain::SandboxPlan>,
structured_policy: Option<&StructuredProviderLaunchPolicy>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
self.starts.lock().unwrap().push((
ctx.clone(),
@ -282,6 +287,7 @@ impl AgentSessionFactory for FakeFactory {
requester.map(str::to_owned),
env.to_vec(),
sandbox.cloned(),
structured_policy.cloned(),
));
Ok(Arc::new(FakeSession {
id: SessionId::new_random(),
@ -320,6 +326,26 @@ impl EventBus for SpyBus {
}
}
struct FakeSystemPermissionStore(ProjectSystemPermissions);
#[async_trait]
impl SystemPermissionStore for FakeSystemPermissionStore {
async fn load_system_permissions(
&self,
_project: &Project,
) -> Result<ProjectSystemPermissions, StoreError> {
Ok(self.0.clone())
}
async fn save_system_permissions(
&self,
_project: &Project,
_permissions: &ProjectSystemPermissions,
) -> Result<(), StoreError> {
Ok(())
}
}
#[tokio::test]
async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_events() {
let issue = issue(7);
@ -415,3 +441,56 @@ async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_
DomainEvent::TicketAssistantClosed { issue_ref } if issue_ref == issue.reference()
));
}
#[tokio::test]
async fn codex_ticket_assistant_uses_project_network_permission_for_structured_policy() {
for (policy, network_access) in [
(Some(NetworkPolicy::Allow), true),
(Some(NetworkPolicy::Deny), false),
(Some(NetworkPolicy::Ask), false),
(None, false),
] {
let issue = issue(7);
let profile_id = ProfileId::from_uuid(Uuid::from_u128(9));
let structured = Arc::new(StructuredSessions::new());
let policies = Arc::new(FakePolicies::default());
let events = Arc::new(SpyBus::default());
let factory = Arc::new(FakeFactory::default());
let system_doc =
ProjectSystemPermissions::new(Some(SystemPermissionSet::new(policy)), Vec::new());
let open = OpenTicketAssistant::new(
Arc::new(FakeIssues {
issue: issue.clone(),
}),
Arc::new(FakeProfiles {
profile: profile(profile_id).with_structured_adapter(StructuredAdapter::Codex),
}),
Arc::new(FakeAssistantContext::default()),
Arc::new(FakeEnvironmentPreparer::default()),
factory.clone(),
structured,
policies,
events,
)
.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));
open.execute(OpenTicketAssistantInput {
project: project(),
issue_ref: issue.reference(),
profile_id,
})
.await
.unwrap();
let starts = factory.starts.lock().unwrap();
assert_eq!(
starts[0].6,
Some(StructuredProviderLaunchPolicy::Codex {
sandbox_mode: "workspace-write".to_owned(),
writable_roots: vec!["/tmp/project".to_owned()],
network_access,
}),
"policy {policy:?} must map to the Codex structured network flag"
);
}
}

View File

@ -1612,6 +1612,9 @@ impl BackendCore {
let template_tool_provider: Arc<dyn TemplateToolProvider> = template_tool_binder.clone();
let tool_policy_registry = Arc::new(ToolPolicyRegistry::new());
let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>;
let system_permission_store = Arc::new(FsSystemPermissionStore::new(Arc::clone(&fs_port)));
let system_permission_store_port =
Arc::clone(&system_permission_store) as Arc<dyn SystemPermissionStore>;
let assistant_context_provider = Arc::new(FsAssistantContextStore::new(
Arc::clone(&fs_port),
app_data_dir.to_string_lossy().into_owned(),
@ -1632,16 +1635,19 @@ impl BackendCore {
}),
Arc::clone(&secret_store_port),
)) as Arc<dyn StructuredSessionEnvironmentPreparer>;
let open_ticket_assistant = Arc::new(OpenTicketAssistant::new(
Arc::clone(&issue_store_port),
Arc::clone(&profile_store_port),
assistant_context_provider,
assistant_environment,
Arc::clone(&session_factory),
Arc::clone(&structured_sessions),
Arc::clone(&tool_policy_store),
Arc::clone(&events_port),
));
let open_ticket_assistant = Arc::new(
OpenTicketAssistant::new(
Arc::clone(&issue_store_port),
Arc::clone(&profile_store_port),
assistant_context_provider,
assistant_environment,
Arc::clone(&session_factory),
Arc::clone(&structured_sessions),
Arc::clone(&tool_policy_store),
Arc::clone(&events_port),
)
.with_system_permission_store(Arc::clone(&system_permission_store_port)),
);
let close_ticket_assistant = Arc::new(CloseTicketAssistant::new(
Arc::clone(&structured_sessions),
tool_policy_store,
@ -1651,9 +1657,6 @@ impl BackendCore {
// --- Project permissions (LP1) ---
let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port)));
let permission_store_port = Arc::clone(&permission_store) as Arc<dyn PermissionStore>;
let system_permission_store = Arc::new(FsSystemPermissionStore::new(Arc::clone(&fs_port)));
let system_permission_store_port =
Arc::clone(&system_permission_store) as Arc<dyn SystemPermissionStore>;
let runtime_permission_probe =
Arc::new(ReadOnlyRuntimePermissionProbe) as Arc<dyn RuntimePermissionProbe>;
let get_project_system_permissions = Arc::new(GetProjectSystemPermissions::new(
@ -1853,6 +1856,7 @@ impl BackendCore {
)
.with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback)
.with_permission_store(Arc::clone(&permission_store_port))
.with_system_permission_store(Arc::clone(&system_permission_store_port))
// Reprise conversationnelle (lot P7) : à chaque (re)lancement, si la cellule
// porte une conversation et qu'un handoff existe (`<root>/.ideai/conversations/`),
// son résumé est réinjecté dans le convention file. Best-effort, additif :
@ -1900,6 +1904,7 @@ impl BackendCore {
)
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
.with_permission_store(Arc::clone(&permission_store_port))
.with_system_permission_store(Arc::clone(&system_permission_store_port))
.with_handoff_provider(
Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>
)

View File

@ -922,11 +922,12 @@ pub trait PermissionProjector: Send + Sync {
/// Computes the projection.
///
/// `eff == None` ⇒ the **empty** projection ([`PermissionProjection::empty`]):
/// we keep the CLI's native prompting (the product invariant of [`resolve`]).
/// `eff == None` means no filesystem/bash policy is projected. Implementations
/// may still project orthogonal system permissions, such as network env.
fn project(
&self,
eff: Option<&EffectivePermissions>,
network: Option<crate::system_permissions::NetworkPolicy>,
ctx: &ProjectionContext,
) -> PermissionProjection;

View File

@ -146,6 +146,28 @@ pub struct StructuredSessionEnvironment {
pub cwd: ProjectPath,
/// Environment variables to pass to the structured session.
pub env: Vec<(String, String)>,
/// Provider-specific launch policy to pass to the structured session.
pub structured_policy: Option<StructuredProviderLaunchPolicy>,
}
/// Provider-specific launch policy for structured/headless sessions.
///
/// This is intentionally separate from [`PermissionProjection`]: the projection is
/// a generic CLI advisory plan (files/argv/env) while structured adapters may need
/// a smaller, command-compatible contract. In particular, `codex exec` accepts
/// `--sandbox` and `--add-dir`, but must never receive the interactive
/// `--ask-for-approval` flag.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StructuredProviderLaunchPolicy {
/// Policy subset supported by `codex exec`.
Codex {
/// Codex sandbox mode (`read-only`, `workspace-write`, ...).
sandbox_mode: String,
/// Workspace roots to pass as repeated `--add-dir` values.
writable_roots: Vec<String>,
/// Whether Codex workspace-write sandbox network access must be enabled.
network_access: bool,
},
}
/// Errors returned while preparing the IdeA-owned ticket assistant context.
@ -1118,6 +1140,13 @@ pub trait AgentSessionFactory: Send + Sync {
/// franchit le port en tant que **valeur domaine** ([`crate::sandbox::SandboxPlan`]) ;
/// l'enforcer concret reste côté infra (injecté par instance dans la fabrique).
///
/// `structured_policy` est une projection optionnelle, provider-spécifique et
/// compatible avec la commande structurée. Elle ne remplace pas `sandbox` :
/// `sandbox` reste l'autorité OS, tandis que cette politique configure le CLI.
/// Elle est volontairement séparée de [`PermissionProjector`] pour éviter de
/// faire porter au projector le contrat exact de sous-commandes comme
/// `codex exec`.
///
/// # Errors
/// [`AgentSessionError::Start`] si la CLI/SDK est indisponible ou le mode
/// structuré ne peut s'initialiser.
@ -1130,6 +1159,7 @@ pub trait AgentSessionFactory: Send + Sync {
requester: Option<&str>,
env: &[(String, String)],
sandbox: Option<&crate::sandbox::SandboxPlan>,
structured_policy: Option<&StructuredProviderLaunchPolicy>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError>;
}

View File

@ -1,8 +1,8 @@
//! System-level agent permissions, distinct from filesystem/bash permissions.
//!
//! This model stores the policy the user wants IdeA to apply. It does not claim
//! that an external assistant runtime can be elevated live: resolution combines
//! the wanted policy with a read-only runtime probe.
//! that an external assistant runtime can be elevated live: resolution exposes
//! the wanted policy separately from the observed runtime state.
use serde::{Deserialize, Serialize};
@ -235,11 +235,22 @@ pub struct RuntimePermissionSnapshot {
pub effective_network: NetworkPolicy,
/// Runtime lock details.
pub runtime_lock: RuntimeLock,
/// Control state exposed to the UI.
pub control: SystemPermissionControl,
/// Runtime control state, distinct from editing the persisted wanted policy.
pub runtime_control: SystemPermissionControl,
}
impl RuntimePermissionSnapshot {
/// Snapshot for an agent with no known active runtime lock.
#[must_use]
pub fn unobserved() -> Self {
const REASON: &str = "No active runtime network permission state is currently observed.";
Self {
effective_network: NetworkPolicy::Deny,
runtime_lock: RuntimeLock::none(),
runtime_control: SystemPermissionControl::read_only(REASON),
}
}
/// Snapshot for a runtime that IdeA cannot inspect or elevate.
#[must_use]
pub fn locked_uninspectable() -> Self {
@ -248,7 +259,7 @@ impl RuntimePermissionSnapshot {
Self {
effective_network: NetworkPolicy::Deny,
runtime_lock: RuntimeLock::locked("external-runtime", REASON),
control: SystemPermissionControl::read_only(REASON),
runtime_control: SystemPermissionControl::read_only(REASON),
}
}
}
@ -263,8 +274,10 @@ pub struct ResolvedAgentSystemPermissions {
pub effective: NetworkPolicy,
/// Runtime lock state.
pub runtime_lock: RuntimeLock,
/// Whether the UI can edit the policy live.
/// Whether the UI can edit the persisted wanted policy.
pub control: SystemPermissionControl,
/// Whether IdeA can inspect or change the active runtime state.
pub runtime_control: SystemPermissionControl,
}
/// Resolves wanted system permissions against the runtime snapshot.
@ -283,7 +296,8 @@ pub fn resolve_agent_system_permissions(
wanted,
effective,
runtime_lock: runtime.runtime_lock,
control: runtime.control,
control: SystemPermissionControl::editable(),
runtime_control: runtime.runtime_control,
}
}
@ -306,7 +320,7 @@ mod tests {
}
#[test]
fn locked_runtime_controls_effective_policy_without_fake_allow() {
fn locked_runtime_controls_effective_policy_without_making_wanted_read_only() {
let agent = AgentId::new_random();
let doc = ProjectSystemPermissions::default();
@ -319,6 +333,31 @@ mod tests {
assert_eq!(resolved.wanted, None);
assert_eq!(resolved.effective, NetworkPolicy::Deny);
assert_eq!(resolved.runtime_lock.state, RuntimeLockState::Locked);
assert_eq!(resolved.control.mode, SystemPermissionControlMode::ReadOnly);
assert_eq!(resolved.control.mode, SystemPermissionControlMode::Editable);
assert_eq!(
resolved.runtime_control.mode,
SystemPermissionControlMode::ReadOnly
);
}
#[test]
fn unobserved_runtime_has_no_active_lock_and_preserves_wanted_effective_policy() {
let agent = AgentId::new_random();
let doc = ProjectSystemPermissions::new(
Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
vec![],
);
let resolved =
resolve_agent_system_permissions(&doc, agent, RuntimePermissionSnapshot::unobserved());
assert_eq!(resolved.wanted, Some(NetworkPolicy::Allow));
assert_eq!(resolved.effective, NetworkPolicy::Allow);
assert_eq!(resolved.runtime_lock.state, RuntimeLockState::None);
assert_eq!(resolved.control.mode, SystemPermissionControlMode::Editable);
assert_eq!(
resolved.runtime_control.mode,
SystemPermissionControlMode::ReadOnly
);
}
}

View File

@ -343,6 +343,7 @@ impl AgentSessionFactory for FakeFactory {
_requester: Option<&str>,
_env: &[(String, String)],
_sandbox: Option<&domain::sandbox::SandboxPlan>,
_structured_policy: Option<&domain::ports::StructuredProviderLaunchPolicy>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
Ok(Arc::new(FakeSession {
id: SessionId::from_uuid(Uuid::from_u128(7)),
@ -398,7 +399,16 @@ async fn fake_factory_supports_only_structured_profiles_and_starts() {
};
let cwd = ProjectPath::new("/srv/run").unwrap();
let session = factory
.start(&structured, &ctx, &cwd, &SessionPlan::None, None, &[], None)
.start(
&structured,
&ctx,
&cwd,
&SessionPlan::None,
None,
&[],
None,
None,
)
.await
.expect("factory starts a session");
assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7)));

View File

@ -314,7 +314,11 @@ impl StructuredSessionEnvironmentPreparer for TicketAssistantEnvironmentPreparer
.await?;
self.materialise_mcp(project, profile, &spec.cwd, requester, &mut env)
.await?;
Ok(StructuredSessionEnvironment { cwd: spec.cwd, env })
Ok(StructuredSessionEnvironment {
cwd: spec.cwd,
env,
structured_policy: None,
})
}
}

View File

@ -31,6 +31,7 @@ impl PermissionProjector for ClaudePermissionProjector {
fn project(
&self,
eff: Option<&EffectivePermissions>,
_network: Option<domain::NetworkPolicy>,
ctx: &ProjectionContext,
) -> PermissionProjection {
// Product invariant: nothing posed ⇒ nothing projected (native prompting).
@ -222,7 +223,7 @@ mod tests {
/// Projects and returns the parsed `settings.local.json` value, asserting the
/// projection's structural contract (1 Replace file, no args/env) along the way.
fn project_json(eff: &EffectivePermissions, root: &str) -> Value {
let proj = ClaudePermissionProjector.project(Some(eff), &ctx(root, "/run/agent"));
let proj = ClaudePermissionProjector.project(Some(eff), None, &ctx(root, "/run/agent"));
assert!(proj.args.is_empty(), "Claude projection carries no args");
assert!(proj.env.is_empty(), "Claude projection carries no env");
assert_eq!(proj.files.len(), 1, "exactly one file projected");
@ -248,7 +249,7 @@ mod tests {
#[test]
fn project_none_is_empty() {
let proj = ClaudePermissionProjector.project(None, &ctx("/proj", "/run"));
let proj = ClaudePermissionProjector.project(None, None, &ctx("/proj", "/run"));
assert!(proj.files.is_empty());
assert!(proj.args.is_empty());
assert!(proj.env.is_empty());

View File

@ -1,7 +1,7 @@
//! Codex CLI permission projector (lot LP3-2).
//!
//! Produces the **permission-relevant** part of Codex's `config.toml`
//! (`sandbox_mode` / `approval_policy`) plus the matching launch args
//! (`sandbox_mode` / `approval_policy` / `sandbox_workspace_write.network_access`) plus the matching launch args
//! (`--sandbox` / `--ask-for-approval`) and, for workspace-write postures, the
//! project root as an additional writable directory (`--add-dir`). The
//! posture→mode derivation is extracted verbatim from the former
@ -11,7 +11,7 @@
//! Unlike Claude's seed, Codex's `config.toml` is **co-owned** (it also carries the
//! `mcp_servers.idea` table and the `projects.*` trust entries, which are MCP/trust
//! concerns, not permissions). The projector therefore emits a
//! [`ProjectedFile::MergeToml`] limited to the two permission keys it manages —
//! [`ProjectedFile::MergeToml`] limited to the permission keys/table it manages —
//! everything else in the file is preserved by the fold, and the file is **never**
//! deleted on swap (hence an empty `owned_replace_paths`).
@ -19,6 +19,7 @@ use domain::permission::{
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
ProjectionContext, ProjectorKey,
};
use domain::NetworkPolicy;
use super::toml_string;
@ -27,10 +28,13 @@ use super::toml_string;
/// the file lives at `.codex/config.toml` relative to the run dir.
const CONFIG_REL_PATH: &str = ".codex/config.toml";
/// The two top-level keys this projector manages in `config.toml`. Everything else
/// The top-level keys this projector manages in `config.toml`. Everything else
/// (MCP table, trust entries, user keys) is preserved by the merge.
const MANAGED_KEYS: [&str; 2] = ["sandbox_mode", "approval_policy"];
/// Codex workspace-write sandbox table owned by IdeA for network projection.
const SANDBOX_WORKSPACE_WRITE_TABLE: &str = "sandbox_workspace_write";
/// Projects [`EffectivePermissions`] into Codex's sandbox/approval config + args.
///
/// Pure: `project` only computes the plan; the launch path merges the TOML fragment
@ -46,23 +50,31 @@ impl PermissionProjector for CodexPermissionProjector {
fn project(
&self,
eff: Option<&EffectivePermissions>,
network: Option<NetworkPolicy>,
ctx: &ProjectionContext,
) -> PermissionProjection {
// Product invariant: nothing posed ⇒ nothing projected. Codex keeps its
// native sandbox/approval defaults (no args, no managed keys written).
// No filesystem/bash policy ⇒ no sandbox/approval projection. Network is
// orthogonal and still gets an explicit env override to avoid stale inheritance.
let Some(permissions) = eff else {
return PermissionProjection::empty();
return PermissionProjection {
files: vec![codex_network_file(network)],
env: codex_network_env(network),
..PermissionProjection::empty()
};
};
let sandbox = codex_sandbox_mode(permissions);
let approval = codex_approval_policy(permissions);
let network_access = codex_network_access(network);
// Permission-only TOML fragment (escaped exactly like the former
// `set_top_level_toml_value`). The mcp_servers/trust tables are NOT a
// permission concern and stay with the MCP wiring (LP3-3).
let contents = format!(
"sandbox_mode = {}\napproval_policy = {}\n",
"sandbox_mode = {}\napproval_policy = {}\n\n[{}]\nnetwork_access = {}\n",
toml_string(sandbox),
toml_string(approval),
SANDBOX_WORKSPACE_WRITE_TABLE,
network_access,
);
let mut args = vec![
@ -79,12 +91,12 @@ impl PermissionProjector for CodexPermissionProjector {
PermissionProjection {
files: vec![ProjectedFile::MergeToml {
rel_path: CONFIG_REL_PATH.to_owned(),
managed_tables: Vec::new(),
managed_tables: vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()],
managed_keys: MANAGED_KEYS.iter().map(|k| (*k).to_owned()).collect(),
contents,
}],
args,
env: Vec::new(),
env: codex_network_env(network),
}
}
@ -94,6 +106,38 @@ impl PermissionProjector for CodexPermissionProjector {
}
}
fn codex_network_file(network: Option<NetworkPolicy>) -> ProjectedFile {
ProjectedFile::MergeToml {
rel_path: CONFIG_REL_PATH.to_owned(),
managed_tables: vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()],
managed_keys: Vec::new(),
contents: format!(
"[{}]\nnetwork_access = {}\n",
SANDBOX_WORKSPACE_WRITE_TABLE,
codex_network_access(network),
),
}
}
fn codex_network_env(network: Option<NetworkPolicy>) -> Vec<(String, String)> {
// Codex inherits the parent environment by default. Always set the variable for
// Codex launches so a stale `CODEX_SANDBOX_NETWORK_DISABLED=1` in IdeA's own
// environment cannot leak into a newly allowed child.
let disabled = if codex_network_access(network) {
"0"
} else {
"1"
};
vec![(
"CODEX_SANDBOX_NETWORK_DISABLED".to_owned(),
disabled.to_owned(),
)]
}
fn codex_network_access(network: Option<NetworkPolicy>) -> bool {
matches!(network, Some(NetworkPolicy::Allow))
}
fn codex_sandbox_mode(permissions: &EffectivePermissions) -> &'static str {
match permissions.fallback() {
Posture::Deny => "read-only",
@ -130,10 +174,33 @@ mod tests {
#[test]
fn project_none_is_empty() {
let proj = CodexPermissionProjector.project(None, &ctx());
assert!(proj.files.is_empty());
let proj = CodexPermissionProjector.project(None, None, &ctx());
assert_eq!(proj.files.len(), 1);
assert!(proj.args.is_empty());
assert!(proj.env.is_empty());
match &proj.files[0] {
ProjectedFile::MergeToml {
rel_path,
managed_tables,
managed_keys,
contents,
} => {
assert_eq!(rel_path, CONFIG_REL_PATH);
assert_eq!(
managed_tables,
&vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()]
);
assert!(managed_keys.is_empty());
assert!(
contents.contains("[sandbox_workspace_write]\nnetwork_access = false"),
"network is denied by default: {contents:?}"
);
}
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
}
assert_eq!(
proj.env,
vec![("CODEX_SANDBOX_NETWORK_DISABLED".to_owned(), "1".to_owned())]
);
}
#[test]
@ -152,8 +219,12 @@ mod tests {
(Posture::Ask, "workspace-write", "on-request"),
(Posture::Allow, "workspace-write", "never"),
] {
let proj = CodexPermissionProjector.project(Some(&eff(posture)), &ctx());
assert!(proj.env.is_empty(), "Codex projection carries no env");
let proj = CodexPermissionProjector.project(Some(&eff(posture)), None, &ctx());
assert_eq!(
proj.env,
vec![("CODEX_SANDBOX_NETWORK_DISABLED".to_owned(), "1".to_owned())],
"Codex projection denies network by default"
);
// -- The single MergeToml file, with the two managed permission keys.
assert_eq!(proj.files.len(), 1, "exactly one file projected");
@ -165,7 +236,10 @@ mod tests {
contents,
} => {
assert_eq!(rel_path, CONFIG_REL_PATH);
assert!(managed_tables.is_empty(), "no managed tables");
assert_eq!(
managed_tables,
&vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()]
);
assert_eq!(
managed_keys,
&vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()]
@ -178,6 +252,10 @@ mod tests {
contents.contains(&format!("approval_policy = \"{approval}\"")),
"posture {posture:?}: contents={contents:?}"
);
assert!(
contents.contains("[sandbox_workspace_write]\nnetwork_access = false"),
"network is denied by default: {contents:?}"
);
}
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
}
@ -210,4 +288,34 @@ mod tests {
}
}
}
#[test]
fn network_policy_maps_to_stale_proof_env() {
for (network, disabled, network_access) in [
(Some(NetworkPolicy::Allow), "0", true),
(Some(NetworkPolicy::Deny), "1", false),
(Some(NetworkPolicy::Ask), "1", false),
(None, "1", false),
] {
let proj =
CodexPermissionProjector.project(Some(&eff(Posture::Allow)), network, &ctx());
assert_eq!(
proj.env,
vec![(
"CODEX_SANDBOX_NETWORK_DISABLED".to_owned(),
disabled.to_owned()
)],
"network={network:?}"
);
match &proj.files[0] {
ProjectedFile::MergeToml { contents, .. } => assert!(
contents.contains(&format!(
"[sandbox_workspace_write]\nnetwork_access = {network_access}"
)),
"network={network:?}: {contents:?}"
),
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
}
}
}
}

View File

@ -7,8 +7,7 @@ use async_trait::async_trait;
use domain::ports::{RuntimeError, RuntimePermissionProbe};
use domain::{AgentId, Project, RuntimePermissionSnapshot};
/// Conservative probe used when IdeA cannot inspect or pilot runtime network
/// permissions.
/// Passive probe used when IdeA has no active runtime network telemetry.
#[derive(Debug, Clone, Default)]
pub struct ReadOnlyRuntimePermissionProbe;
@ -19,6 +18,6 @@ impl RuntimePermissionProbe for ReadOnlyRuntimePermissionProbe {
_project: &Project,
_agent_id: AgentId,
) -> Result<RuntimePermissionSnapshot, RuntimeError> {
Ok(RuntimePermissionSnapshot::locked_uninspectable())
Ok(RuntimePermissionSnapshot::unobserved())
}
}

View File

@ -154,8 +154,12 @@ pub struct CodexExecSession {
command: String,
/// Répertoire de travail (run dir isolé §14.1).
cwd: String,
/// Codex CLI sandbox mode passed to `codex exec --sandbox`.
sandbox_mode: String,
/// Project/workspace roots that must be writable in Codex's CLI sandbox.
writable_roots: Vec<String>,
/// Structured policy projection of Codex workspace-write sandbox network access.
network_access: Option<bool>,
/// Variables d'environnement préparées au lancement (ex. `CODEX_HOME` isolé).
env: Vec<(String, String)>,
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
@ -183,12 +187,44 @@ impl CodexExecSession {
env: Vec<(String, String)>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
) -> Self {
Self::new_with_policy(
id,
command,
cwd,
seed_conversation_id,
"workspace-write",
writable_roots,
None,
env,
sandbox,
sandbox_enforcer,
)
}
/// Construit l'adapter avec la politique compatible `codex exec` résolue par
/// `LaunchAgent`.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new_with_policy(
id: SessionId,
command: impl Into<String>,
cwd: impl Into<String>,
seed_conversation_id: Option<String>,
sandbox_mode: impl Into<String>,
writable_roots: Vec<String>,
network_access: Option<bool>,
env: Vec<(String, String)>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
) -> Self {
Self {
id,
command: command.into(),
cwd: cwd.into(),
sandbox_mode: sandbox_mode.into(),
writable_roots,
network_access,
env,
conversation_id: Mutex::new(seed_conversation_id),
sandbox,
@ -217,28 +253,54 @@ impl CodexExecSession {
let conversation_id = self.conversation_id.lock().expect("mutex sain").clone();
args.push("--json".to_owned());
args.push("--skip-git-repo-check".to_owned());
args.push("--sandbox".to_owned());
args.push("workspace-write".to_owned());
for root in self.writable_roots.iter().filter(|root| !root.is_empty()) {
args.push("--add-dir".to_owned());
args.push(root.clone());
if !self.sandbox_mode.trim().is_empty() {
args.push("--sandbox".to_owned());
args.push(self.sandbox_mode.clone());
}
if self.sandbox_mode == "workspace-write" {
for root in self.writable_roots.iter().filter(|root| !root.is_empty()) {
args.push("--add-dir".to_owned());
args.push(root.clone());
}
}
if let Some(network_access) = self.network_access {
args.push("-c".to_owned());
args.push(format!(
"sandbox_workspace_write.network_access={network_access}"
));
}
if let Some(id) = conversation_id {
args.push("resume".to_owned());
args.push(id);
}
args.push(prompt.to_owned());
let mut env = self.env.clone();
if let Some(network_access) = self.network_access {
upsert_env(
&mut env,
"CODEX_SANDBOX_NETWORK_DISABLED",
if network_access { "0" } else { "1" },
);
}
SpawnLine {
command: self.command.clone(),
args,
cwd: self.cwd.clone(),
env: self.env.clone(),
env,
stdin: None,
sandbox: self.sandbox.clone(),
}
}
}
fn upsert_env(env: &mut Vec<(String, String)>, key: &str, value: &str) {
if let Some((_, existing)) = env.iter_mut().find(|(k, _)| k == key) {
*existing = value.to_owned();
} else {
env.push((key.to_owned(), value.to_owned()));
}
}
#[async_trait]
impl AgentSession for CodexExecSession {
fn id(&self) -> SessionId {

View File

@ -14,7 +14,7 @@ use serde_json::Value;
use domain::ports::{
AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, SessionPlan,
ToolInvocationError, ToolInvoker, ToolSpec,
StructuredProviderLaunchPolicy, ToolInvocationError, ToolInvoker, ToolSpec,
};
use domain::profile::{AgentProfile, StructuredAdapter};
use domain::project::ProjectPath;
@ -139,6 +139,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
requester: Option<&str>,
env: &[(String, String)],
sandbox: Option<&SandboxPlan>,
structured_policy: Option<&StructuredProviderLaunchPolicy>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
let adapter = profile.structured_adapter.ok_or_else(|| {
AgentSessionError::Start(format!(
@ -171,16 +172,36 @@ impl AgentSessionFactory for StructuredSessionFactory {
StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(
id, command, cwd, seed, plan, enforcer,
)),
StructuredAdapter::Codex => Arc::new(CodexExecSession::new(
id,
command,
cwd,
seed,
vec![ctx.project_root.clone()],
env.to_vec(),
plan,
enforcer,
)),
StructuredAdapter::Codex => {
let policy = match structured_policy {
Some(StructuredProviderLaunchPolicy::Codex {
sandbox_mode,
writable_roots,
network_access,
}) => (
sandbox_mode.clone(),
writable_roots.clone(),
Some(*network_access),
),
None => (
"workspace-write".to_owned(),
vec![ctx.project_root.clone()],
None,
),
};
Arc::new(CodexExecSession::new_with_policy(
id,
command,
cwd,
seed,
policy.0,
policy.1,
policy.2,
env.to_vec(),
plan,
enforcer,
))
}
StructuredAdapter::OpenCode => Arc::new(OpenCodeSession::new(
id,
profile.command.clone(),

View File

@ -513,6 +513,7 @@ mod tests {
None,
&[],
None,
None,
)
.await
.expect("start Claude ok");
@ -531,6 +532,7 @@ mod tests {
None,
&[],
None,
None,
)
.await
.expect("start Codex ok");
@ -563,6 +565,7 @@ mod tests {
None,
&[],
None,
None,
)
.await
.expect("start OpenAI-compatible ok");
@ -592,7 +595,16 @@ mod tests {
};
let session = factory
.start(&codex, &ctx, &cwd(), &SessionPlan::None, None, &[], None)
.start(
&codex,
&ctx,
&cwd(),
&SessionPlan::None,
None,
&[],
None,
None,
)
.await
.expect("start Codex ok");
let content = drain_final(session.as_ref()).await;
@ -629,6 +641,7 @@ mod tests {
None,
&[],
None,
None,
)
.await
.expect("start resume ok");
@ -1444,6 +1457,7 @@ mod tests {
None,
&[],
None,
None,
)
.await
.expect("start resume codex");
@ -1469,6 +1483,7 @@ mod tests {
None,
&[],
None,
None,
)
.await
.expect("start assign");
@ -1501,6 +1516,7 @@ mod tests {
None,
&[],
None,
None,
)
.await
{
@ -1667,8 +1683,7 @@ mod tests {
// =====================================================================
// DURCISSEMENT QA (lot D3, §17.9 D3 — fix codex 0.137) — autonomie
// d'écriture Codex : la commande générée porte EXACTEMENT
// [exec, --json, --skip-git-repo-check, --sandbox, workspace-write,
// --add-dir, <project-root>, <prompt>]
// [exec, --json, --skip-git-repo-check, --sandbox, <mode>, --add-dir, <project-root>, <prompt>]
// (`resume <id>` après les options `exec` pour une reprise). Le flag `--ask-for-approval never`
// a été RETIRÉ : `codex exec` 0.137 ne le connaît pas (`error: unexpected
// argument`) et est déjà non-interactif. Ce test verrouille l'argv exact pour
@ -1759,6 +1774,186 @@ mod tests {
let _ = std::fs::remove_file(&argv);
}
#[tokio::test]
async fn codex_read_only_policy_omits_add_dir_and_approval_flag() {
let (cmd, argv) = make_recording_fake(&[
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let session = CodexExecSession::new_with_policy(
SessionId::new_random(),
cmd.clone(),
"/",
None,
"read-only",
vec!["/project/root".to_owned()],
Some(false),
Vec::new(),
None,
None,
);
let _ = session.send("analyse").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
assert_eq!(
args,
vec![
"exec",
"--json",
"--skip-git-repo-check",
"--sandbox",
"read-only",
"-c",
"sandbox_workspace_write.network_access=false",
"analyse",
],
"read-only must not carry --add-dir or --ask-for-approval and must disable Codex network, got: {args:?}"
);
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
#[tokio::test]
async fn codex_workspace_write_policy_carries_network_config_override() {
let (cmd, argv) = make_recording_fake(&[
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let session = CodexExecSession::new_with_policy(
SessionId::new_random(),
cmd.clone(),
"/",
None,
"workspace-write",
vec!["/project/root".to_owned()],
Some(true),
Vec::new(),
None,
None,
);
let _ = session.send("analyse").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
assert_eq!(
args,
vec![
"exec",
"--json",
"--skip-git-repo-check",
"--sandbox",
"workspace-write",
"--add-dir",
"/project/root",
"-c",
"sandbox_workspace_write.network_access=true",
"analyse",
],
"workspace-write Allow must pass the official Codex network override, got: {args:?}"
);
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
#[tokio::test]
async fn codex_structured_session_forwards_network_env() {
use std::io::Write as _;
let dir = std::env::current_dir()
.expect("cwd")
.join("target")
.join("test-fakes")
.join("session");
std::fs::create_dir_all(&dir).expect("fake dir");
let bin = dir.join(format!("idea-env-cli-{}", std::process::id()));
let env_file = dir.join(format!("idea-env-rec-{}", std::process::id()));
let script = format!(
"#!/bin/sh\nprintf '%s\\n' \"$CODEX_SANDBOX_NETWORK_DISABLED\" > '{}'\nprintf '%s\\n' '{}'\n",
env_file.display(),
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#
);
{
let mut f = std::fs::File::create(&bin).expect("create fake");
f.write_all(script.as_bytes()).expect("write fake");
f.sync_all().expect("sync fake");
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut p = std::fs::metadata(&bin).unwrap().permissions();
p.set_mode(0o755);
std::fs::set_permissions(&bin, p).unwrap();
}
super::conformance::wait_until_executable(&bin);
let session = CodexExecSession::new(
SessionId::new_random(),
bin.to_string_lossy().into_owned(),
"/",
None,
Vec::new(),
vec![("CODEX_SANDBOX_NETWORK_DISABLED".to_owned(), "0".to_owned())],
None,
None,
);
let _ = session.send("salut").await.expect("send ok");
let recorded = std::fs::read_to_string(&env_file).expect("env file");
assert_eq!(recorded.trim(), "0");
let _ = std::fs::remove_file(&bin);
let _ = std::fs::remove_file(&env_file);
}
#[tokio::test]
async fn codex_structured_policy_overrides_stale_network_env() {
use std::io::Write as _;
let dir = std::env::current_dir()
.expect("cwd")
.join("target")
.join("test-fakes")
.join("session");
std::fs::create_dir_all(&dir).expect("fake dir");
let bin = dir.join(format!("idea-env-override-cli-{}", std::process::id()));
let env_file = dir.join(format!("idea-env-override-rec-{}", std::process::id()));
let script = format!(
"#!/bin/sh\nprintf '%s\\n' \"$CODEX_SANDBOX_NETWORK_DISABLED\" > '{}'\nprintf '%s\\n' '{}'\n",
env_file.display(),
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#
);
{
let mut f = std::fs::File::create(&bin).expect("create fake");
f.write_all(script.as_bytes()).expect("write fake");
f.sync_all().expect("sync fake");
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut p = std::fs::metadata(&bin).unwrap().permissions();
p.set_mode(0o755);
std::fs::set_permissions(&bin, p).unwrap();
}
super::conformance::wait_until_executable(&bin);
let session = CodexExecSession::new_with_policy(
SessionId::new_random(),
bin.to_string_lossy().into_owned(),
"/",
None,
"workspace-write",
Vec::new(),
Some(true),
vec![("CODEX_SANDBOX_NETWORK_DISABLED".to_owned(), "1".to_owned())],
None,
None,
);
let _ = session.send("salut").await.expect("send ok");
let recorded = std::fs::read_to_string(&env_file).expect("env file");
assert_eq!(recorded.trim(), "0");
let _ = std::fs::remove_file(&bin);
let _ = std::fs::remove_file(&env_file);
}
// =====================================================================
// LS2 — adapter Claude niveau 1 (§21) : `parse_reset_ms` (parseur ISO-8601
// À LA MAIN + heuristique secondes/ms + days_from_civil) et le mapping

View File

@ -478,6 +478,7 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
None,
&[],
Some(&plan),
None,
)
.await
.expect("start sandboxé ok");

View File

@ -483,6 +483,7 @@ impl AgentSessionFactory for BlockingReplyFactory {
_requester: Option<&str>,
_env: &[(String, String)],
_sandbox: Option<&domain::sandbox::SandboxPlan>,
_structured_policy: Option<&domain::ports::StructuredProviderLaunchPolicy>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
Ok(Arc::new(BlockingReplySession {
id: SessionId::from_uuid(Uuid::new_v4()),

View File

@ -0,0 +1,89 @@
//! Integration tests for [`FsSystemPermissionStore`] against a real temp project.
use std::path::PathBuf;
use std::sync::Arc;
use domain::ids::{AgentId, ProjectId};
use domain::ports::{FileSystem, SystemPermissionStore};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::{
AgentSystemPermissionOverride, NetworkPolicy, ProjectSystemPermissions, SystemPermissionSet,
SYSTEM_PERMISSIONS_VERSION,
};
use infrastructure::{FsSystemPermissionStore, LocalFileSystem};
use uuid::Uuid;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("idea-system-permissions-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
fn project_root(&self) -> String {
self.0.to_string_lossy().into_owned()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn store() -> FsSystemPermissionStore {
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
FsSystemPermissionStore::new(fs)
}
fn project(tmp: &TempDir) -> Project {
Project::new(
ProjectId::new_random(),
"system-permissions",
ProjectPath::new(tmp.project_root()).unwrap(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap()
}
#[tokio::test]
async fn missing_system_permissions_file_returns_default_document() {
let tmp = TempDir::new();
let project = project(&tmp);
let loaded = store().load_system_permissions(&project).await.unwrap();
assert_eq!(loaded, ProjectSystemPermissions::default());
assert_eq!(loaded.version, SYSTEM_PERMISSIONS_VERSION);
}
#[tokio::test]
async fn save_then_load_roundtrips_project_defaults_and_agent_override() {
let tmp = TempDir::new();
let project = project(&tmp);
let agent = AgentId::new_random();
let doc = ProjectSystemPermissions::new(
Some(SystemPermissionSet::new(Some(NetworkPolicy::Ask))),
vec![AgentSystemPermissionOverride::new(
agent,
SystemPermissionSet::new(Some(NetworkPolicy::Deny)),
)],
);
let store = store();
store.save_system_permissions(&project, &doc).await.unwrap();
let loaded = store.load_system_permissions(&project).await.unwrap();
assert_eq!(loaded, doc);
assert_eq!(loaded.wanted_network_for(agent), Some(NetworkPolicy::Deny));
let path = tmp.0.join(".ideai").join("system-permissions.json");
assert!(
path.exists(),
"store writes under .ideai/system-permissions.json"
);
}

View File

@ -2295,6 +2295,10 @@ export class MockPermissionGateway implements PermissionGateway {
effective: wanted ?? "ask",
runtimeLock: { state: "none" },
control: { mode: "editable" },
runtimeControl: {
mode: "readOnly",
reason: "No active runtime network permission state is currently observed.",
},
};
}

View File

@ -787,7 +787,10 @@ export interface ResolvedAgentSystemPermissions {
wanted: NetworkPolicy | null;
effective: NetworkPolicy;
runtimeLock: RuntimeLock;
/** Whether the UI can edit the persisted wanted policy. */
control: SystemPermissionControl;
/** Whether IdeA can inspect or change the active runtime state. */
runtimeControl: SystemPermissionControl;
}
// ---------------------------------------------------------------------------

View File

@ -122,7 +122,8 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
wanted: "allow",
effective: "deny",
runtimeLock: { state: "locked", reason: "Runtime locked." },
control: { mode: "readOnly", reason: "Runtime locked." },
control: { mode: "editable" },
runtimeControl: { mode: "readOnly", reason: "Runtime locked." },
});
renderPanel(agent, new MockProfileGateway(), "/home/me/proj", undefined, permission);

View File

@ -344,7 +344,9 @@ function NetworkPermissionEditor({
const wanted = resolved?.wanted ?? source?.network ?? inherited?.network ?? null;
const effective = resolved?.effective ?? null;
const runtimeLocked = resolved?.runtimeLock.state === "locked";
const lockReason = resolved?.runtimeLock.reason ?? resolved?.control.reason ?? null;
const runtimeReadOnly = resolved?.runtimeControl.mode === "readOnly";
const runtimeReason = resolved?.runtimeLock.reason ?? resolved?.runtimeControl.reason ?? null;
const runtimeUnobserved = resolved?.runtimeLock.state === "none" && runtimeReadOnly;
return (
<section className="rounded-md border border-border bg-surface">
@ -371,7 +373,15 @@ function NetworkPermissionEditor({
/>
<StatusLine
label="Runtime"
value={runtimeLocked ? "Verrouillé par le runtime" : "Aucun verrou connu"}
value={
runtimeLocked
? "Runtime verrouillé"
: runtimeUnobserved
? "Aucun runtime actif"
: runtimeReadOnly
? "Runtime en lecture seule"
: "Runtime modifiable"
}
/>
<StatusLine
label="Control"
@ -381,7 +391,19 @@ function NetworkPermissionEditor({
{readOnly && (
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{lockReason ?? "Le runtime actif ne permet pas de modifier cette permission."}
{resolved?.control.reason ?? "Cette configuration réseau est en lecture seule."}
</p>
)}
{!readOnly && runtimeUnobserved && (
<p className="rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-xs text-primary">
Aucun runtime actif. La configuration sera appliquée au prochain lancement.
</p>
)}
{!readOnly && runtimeLocked && (
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{runtimeReason ?? "Le runtime actif expose un état réseau en lecture seule."}
</p>
)}

View File

@ -102,7 +102,7 @@ describe("PermissionsPanel", () => {
expect(screen.getAllByText("Réseau autorisé").length).toBeGreaterThan(0);
});
it("renders the network control read-only when the resolved runtime says so", async () => {
it("renders the network control read-only when persisted control says so", async () => {
const agent = new MockAgentGateway();
const permission = new MockPermissionGateway();
const created = await agent.createAgent(PROJECT_ID, {
@ -118,6 +118,7 @@ describe("PermissionsPanel", () => {
reason: "Runtime network is locked.",
},
control: { mode: "readOnly", reason: "Runtime network is locked." },
runtimeControl: { mode: "readOnly", reason: "Runtime network is locked." },
});
const gateways = {
agent,
@ -132,7 +133,7 @@ describe("PermissionsPanel", () => {
);
await waitFor(() => {
expect(screen.getByText("Verrouillé par le runtime")).toBeTruthy();
expect(screen.getByText("Runtime verrouillé")).toBeTruthy();
});
expect(screen.getByText("Runtime network is locked.")).toBeTruthy();
expect(
@ -140,4 +141,95 @@ describe("PermissionsPanel", () => {
.disabled,
).toBe(true);
});
it("keeps wanted network editable when no runtime is active", async () => {
const { permission } = await renderPanel();
await waitFor(() => {
expect(screen.getByText("Aucun runtime actif")).toBeTruthy();
});
expect(
screen.getByText(
"Aucun runtime actif. La configuration sera appliquée au prochain lancement.",
),
).toBeTruthy();
expect(
(screen.getByLabelText("Réseau — defaults projet network") as HTMLSelectElement)
.disabled,
).toBe(false);
fireEvent.change(screen.getByLabelText("Réseau — defaults projet network"), {
target: { value: "deny" },
});
fireEvent.click(screen.getByRole("button", { name: "Save network" }));
await waitFor(async () => {
const doc = await permission.getProjectSystemPermissions(PROJECT_ID);
expect(doc.projectDefault?.network).toBe("deny");
});
});
it("keeps an idle agent editable by default and only enables Save after a change", async () => {
await renderPanel();
fireEvent.click(screen.getByText("Builder"));
await waitFor(() => {
expect(screen.getByText("Aucun runtime actif")).toBeTruthy();
});
expect(screen.queryByText("Lecture seule")).toBeNull();
expect(screen.queryByText("Runtime verrouillé")).toBeNull();
const select = screen.getByLabelText("Réseau — Builder network") as HTMLSelectElement;
const save = screen.getByRole("button", { name: "Save network" });
expect(select.disabled).toBe(false);
expect(save).toHaveProperty("disabled", true);
fireEvent.change(select, { target: { value: "allow" } });
expect(save).toHaveProperty("disabled", false);
});
it("keeps wanted network editable when only runtime control is read-only", async () => {
const agent = new MockAgentGateway();
const permission = new MockPermissionGateway();
const created = await agent.createAgent(PROJECT_ID, {
name: "Builder",
profileId: "p1",
});
permission.setResolvedAgentSystemPermissions(created.id, {
wanted: "allow",
effective: "deny",
runtimeLock: {
state: "locked",
source: "external-runtime",
reason: "IdeA cannot change this active runtime.",
},
control: { mode: "editable" },
runtimeControl: {
mode: "readOnly",
reason: "IdeA cannot change this active runtime.",
},
});
const gateways = {
agent,
permission,
profile: new MockProfileGateway(),
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<PermissionsPanel projectId={PROJECT_ID} />
</DIProvider>,
);
await waitFor(() => {
expect(screen.getByText("Runtime verrouillé")).toBeTruthy();
});
expect(
(screen.getByLabelText("Réseau — defaults projet network") as HTMLSelectElement)
.disabled,
).toBe(false);
});
});

View File

@ -61,7 +61,8 @@ describe("TerminalView (with MockTerminalGateway)", () => {
wanted: "allow",
effective: "deny",
runtimeLock: { state: "locked", reason: "Runtime network locked." },
control: { mode: "readOnly", reason: "Runtime network locked." },
control: { mode: "editable" },
runtimeControl: { mode: "readOnly", reason: "Runtime network locked." },
},
});