Date : 15/02/2026
Status : IMPLEMENTEE
Auditeur : Claude Opus 4.6
Projet : /stock_8to/33800-stack/projects/ulias-org/
Scope : Integration connectors-api, ai-orchestrator, WebSocket lifecycle, Agent lifecycle, Session management, Configuration
| Severite | Nombre |
|---|---|
| BUG CERTAIN | 4 |
| BUG PROBABLE | 8 |
| RISQUE THEORIQUE | 9 |
| TOTAL | 21 |
Les principaux problemes identifies sont :
.id, GitLab/AI utilise .name, ntfy alterne) -- contrat fragilews morteinstance parameter (id vs name) dans connectorFetchpackages/server/src/tools/client.ts : lignes 504, 517, 535, 566, 576, 588, 597, 607, 617, 627 (supabase = .id), 332, 380, 386, 394 (ai-orchestrator = .name), 647 (gitlab = .name), 799 (ntfy = .id), 811 (mailjet = .name), 835 (loki = .id)connectorFetch() est envoye dans le body JSON comme instance. Selon le connector, le code passe tantot instance.id (UUID), tantot instance.name (string). Le contrat avec connectors-api /api/fetch attend probablement un identifiant coherent. Si connectors-api resout par nom OU par id, ca fonctionne. Mais si la resolution change, certains appels casseront.// Supabase: utilise .id
return this.connectorFetch('supabase', sbInstance.id, ...);
// AI-orchestrator: utilise .name
const job = await this.connectorFetch('ai-orchestrator', aiInstance.name, ...);
// ntfy: utilise .id
await this.connectorFetch('ntfy', ntfyInstance.id, 'send', ...);
// mailjet: utilise .name
return this.connectorFetch('mailjet', mailjetInstance.name, ...);
// gitlab: utilise .name
return this.connectorFetch('gitlab', gitlabInstance.name, path, ...);
// loki: utilise .id
return this.connectorFetch('loki', lokiInstance.id, 'query', ...);
.id partout ou .name partout) et la respecter pour tous les connecteurs.packages/server/src/tools/client.ts:112, conf.prod.gouroubleu.ymlCALLBACK_BASE utilise process.env.CALLBACK_BASE_URL || 'http://192.168.1.12:5515'. Or cette env var n'est definie nulle part : ni dans conf.prod.gouroubleu.yml, ni dans .env.deploy. Le fallback http://192.168.1.12:5515 fonctionne SEULEMENT si ai-orchestrator peut atteindre ulias-org sur cette IP+port. En Docker sur prod-portainer, le container ulias-org ecoute sur 5515 mais son IP interne Docker n'est pas forcement 192.168.1.12.const CALLBACK_BASE = process.env.CALLBACK_BASE_URL || 'http://192.168.1.12:5515';
// ...
callback_url: `${CALLBACK_BASE}/internal/job-callback/${callbackId}`,
waitForCallback sauvera la situation en faisant un poll final, mais avec 5 minutes de latence inutile.CALLBACK_BASE_URL explicitement dans conf.prod.gouroubleu.yml sous env:. Verifier que ai-orchestrator et ulias-org sont sur le meme reseau Docker ou que le port 5515 est bien publie sur l'hote.packages/server/src/index.ts : lignes 221-222 (session.ws = ws), 1219 (close handler), 86-91 (ws.send dans generateConversationTitle)handleWsMessage, lors de l'auth, session.ws = ws stocke la reference WS. Mais dans les handlers close(ws) des endpoints /ws/cli et /ws/webui (lignes 1219, 1233), la session n'est PAS nettoyee : session.ws reste la reference morte. Les appels fire-and-forget comme generateConversationTitle et generateConversationSummary appelleront ensuite ws.send() sur un WS ferme, ce qui provoque soit une exception silencieuse, soit un crash.
// open/auth: stocke ws
session.ws = ws;
// close: ne nettoie PAS session.ws
close(ws) {
console.log([WS] CLI disconnected);
}
// Appel asynchrone sur ws potentiellement ferme ws.send(JSON.stringify({ type: 'conversation_title_updated', conversationId: convId, title, tags, }));
- **Impact** : Crash ou exception non gere quand un titre/resume est genere APRES la deconnexion du WS. Le try/catch dans `generateConversationTitle` masque l'erreur, mais le message est perdu.
- **Fix suggere** : Dans les handlers `close(ws)`, retrouver la session associee et mettre `session.ws = undefined`. Avant chaque `ws.send()`, verifier que le WS est encore ouvert (readyState).
---
### F-04 : Fuite memoire pendingJobs -- jamais nettoye si caller abandonne
- **Severite** : BUG PROBABLE
- **Fichier** : `packages/server/src/tools/client.ts` : lignes 88, 440
- **Description** : `pendingJobs` est un `Map<string, PendingJob>` global. Chaque appel `llmChat`/`llmEmbed`/`whisperTranscribe`/`analyzeImage` ajoute une entree avec un timer de timeout. Si le timeout expire, l'entree est supprimee. MAIS : si le process appelant crash/est annule (AbortSignal) AVANT le timeout, la promesse reste suspendue et le PendingJob avec son timer reste en memoire. Il n'y a aucun cleanup periodique ni integration avec AbortSignal.
- **Code concerne** :
```typescript
pendingJobs.set(callbackId, { resolve, reject, timer, createdAt: Date.now() });
// Pas de AbortSignal listener
// Pas de cleanup periodique des pendingJobs stales
createdAt trop vieux.packages/server/src/briefing/index.ts:149if (!currentQ.required || !skipWords.includes(answer.trim().toLowerCase())) {
session.answers.set(currentQ.id, answer);
}
Avec !currentQ.required || !skipWords.includes(...), la reponse est SAUVEE si : la question est non-required (toujours vrai quel que soit le contenu) OU le mot n'est pas dans skipWords. Pour une question required=true, si l'utilisateur tape "passer", la condition !required est false mais !skipWords.includes("passer") est aussi false, donc la reponse n'est PAS sauvee -- l'utilisateur peut skipper une question obligatoire. Pour une question required=false, !required est true, donc la reponse est TOUJOURS sauvee, meme si l'utilisateur dit "passer" -- le skip ne fonctionne jamais pour les non-required.
if (!currentQ.required || !skipWords.includes(answer.trim().toLowerCase())) {
session.answers.set(currentQ.id, answer);
}
const isSkip = skipWords.includes(answer.trim().toLowerCase());
if (!isSkip || currentQ.required) {
// Si c'est un skip ET la question est required: stocker quand meme (ou refuser)
// Si ce n'est PAS un skip: stocker
if (!isSkip) {
session.answers.set(currentQ.id, answer);
}
// Si skip + required: demander a nouveau (ne pas avancer)
}
packages/server/src/index.ts:16CONNECTORS_API_URL est 'http://192.168.1.12:5400', mais connectors-api ecoute sur le port 5403 (derriere wireguard sidecar, comme documente dans CLAUDE.md). Le conf.prod.gouroubleu.yml definit correctement CONNECTORS_API_URL: "http://192.168.1.12:5403", donc en production le fallback n'est jamais utilise. Mais en dev local sans .env, le port 5400 sera utilise et les requetes echoueront.const CONNECTORS_API_URL = process.env.CONNECTORS_API_URL || 'http://192.168.1.12:5400';
'http://192.168.1.12:5403'.packages/server/src/tools/client.ts:231editFile utilise content.replace(oldString, newString) (sans flag g). String.replace() en JavaScript ne remplace que la premiere occurrence. Si oldString apparait plusieurs fois dans le fichier, seule la premiere sera modifiee.const newContent = content.replace(oldString, newString);
replaceAll() selon le besoin. Le comportement actuel (premiere occurrence seulement) peut etre voulu pour eviter des remplacements accidentels.packages/server/src/agent-runner/index.ts:13-14toolCircuitBreaker et responseCache sont declares au niveau module (global). Tous les agents de toutes les sessions partagent le meme circuit breaker et le meme cache. Si un tool echoue pour un user A, le circuit s'ouvre aussi pour le user B. Si le user A fait une requete LLM, le user B peut recevoir la reponse cachee (si les messages sont identiques -- peu probable mais possible).const toolCircuitBreaker = new CircuitBreaker(3, 60000);
const responseCache = new ResponseCache(50, 5 * 60 * 1000);
packages/server/src/index.ts:1195-1204/internal/job-callback/:callbackId est ouvert sans authentification. N'importe qui avec l'URL peut envoyer un faux resultat de job. Le callbackId est un UUID genere cote client, mais l'endpoint est accessible en HTTP depuis n'importe quel container sur le reseau Docker..post('/internal/job-callback/:callbackId', async ({ params, body }) => {
const { callbackId } = params;
const resolved = resolveJobCallback(callbackId, body as any);
// ...
return { ok: true };
})
packages/server/src/index.ts:213-429handleWsMessage est async et appelle director.handleMessage() qui peut prendre jusqu'a 10 minutes (agent timeout). Pendant ce temps, le handler WS bloque la connexion pour cet utilisateur. Si l'utilisateur envoie un deuxieme message, il sera queue. Il n'y a aucun mecanisme de garde contre les messages concurrents : un utilisateur pourrait envoyer 5 messages rapidement et lancer 5 agents en parallele.async message(ws, raw) {
await handleWsMessage(ws, raw); // peut durer 10 min
}
director.handleMessage() independant. Consommation GPU et memoire non bornee par session.session.busy qui refuse les nouveaux messages tant qu'un agent tourne. Ou implementer une queue par session.packages/server/src/agent-runner/index.ts:311-316user artificiel pour "nudger" le LLM. Ce faux message user reste dans le tableau messages et sera vu par le LLM dans les iterations suivantes, ce qui peut creer de la confusion (le LLM pense que l'utilisateur a envoye un nouveau message).messages.push({
role: 'user',
content: 'Please provide your final answer based on the information gathered so far...',
});
role: 'system' au lieu de role: 'user' pour le nudge, ou supprimer le message apres qu'il a servi.ilike.*query* non escapepackages/server/src/db/index.ts:519, 527searchConversations utilise ilike.*${query}* dans le filtre PostgREST. La variable query vient directement de l'utilisateur et n'est pas echappee. Les caracteres speciaux PostgREST (%, _, ., *) dans la requete utilisateur pourraient modifier la semantique du filtre ou provoquer des erreurs.filter: { user_id: userId, title: `ilike.*${query}*` },
ilike.*v2.0* ou le . matche n'importe quel caractere).packages/server/src/auth.ts:185-198storeApiKey sauvegarde l'API key en clair dans ceo_profile.preferences.connector_api_key dans Supabase. Toute personne ayant acces a la base Supabase (ou aux requetes PostgREST) peut lire les API keys de tous les utilisateurs.const prefs = { ...(profile.preferences || {}), connector_api_key: apiKey };
await fetch(`${connectorsUrl}/api/fetch`, {
// PATCH ceo_profile with plain text API key
body: { preferences: prefs },
});
packages/server/src/db/index.ts:188, 243, 427, 448, 478, 561result?.id || result?.[0]?.id. Si Supabase retourne un objet sans champ id (ex: erreur silencieuse, GRANT manquant), le retour sera undefined. Les appelants utilisent ensuite cet ID sans verification (ex: session.activeConversationId = convId ou task.id = taskDbId).async createObjective(obj: DBObjective): Promise<string> {
const result = await this.client.dbInsert('agents.objectives', { ... });
return result?.id || result?.[0]?.id; // peut retourner undefined
}
const id = result?.id || result?.[0]?.id; if (!id) throw new Error('DB insert did not return an ID');packages/server/src/index.ts:197-210session.ws?.close()) ni n'annule les abort controllers des agents en cours (Director.activeAbortControllers). Le Director associe a la session reste en memoire tant que des references existent.setInterval(() => {
const now = Date.now();
for (const [key, session] of sessions) {
if (now - session.lastActivity > SESSION_IDLE_MS) {
sessions.delete(key);
suggestionsCache.delete(session.userCtx.userId);
// PAS de session.ws?.close()
// PAS de session.director.cancel*()
}
}
}, CLEANUP_INTERVAL_MS);
session.ws?.close() et iterer sur session.director.activeAbortControllers pour annuler les agents en cours..id dans connectorFetch mais auth.ts utilise 'Supabase' (name)Severite : BUG CERTAIN
Fichier : packages/server/src/auth.ts:141,172,192,202,209 vs packages/server/src/tools/client.ts:504
Description : Le fichier auth.ts appelle connectors-api avec instance: 'Supabase' (string literal, le NOM de l'instance). Le fichier client.ts appelle avec sbInstance.id (le UUID). Ce sont deux conventions differentes pour le meme backend Supabase. Si connectors-api resout par nom ET par id, les deux fonctionnent. Mais c'est une incoherence dangereuse.
De plus, auth.ts hardcode le nom 'Supabase' -- si l'instance est renommee ou si l'utilisateur a une instance Supabase avec un nom different, l'auth echouera.
Code concerne :
// auth.ts : hardcode le nom 'Supabase'
body: JSON.stringify({
connector: 'supabase',
instance: 'Supabase',
method: 'GET',
path: `/rest/v1/ceo_profile?user_id=eq.${userId}&select=preferences`,
}),
// client.ts : utilise l'ID dynamique
return this.connectorFetch('supabase', sbInstance.id, rest/v1/${tableName}, ...);
- **Impact** : Si le nom de l'instance Supabase n'est pas exactement `'Supabase'`, les operations de persistance d'API key echoueront silencieusement (try/catch avale l'erreur).
- **Fix suggere** : Dans `auth.ts`, passer l'instance de maniere dynamique via un parametre plutot que de hardcoder `'Supabase'`.
---
### F-17 : Prompt overrides globaux (pas par session/user)
- **Severite** : RISQUE THEORIQUE
- **Fichier** : `packages/server/src/agents/registry.ts:641`, `packages/server/src/index.ts:160-171`
- **Description** : Les prompt overrides sont stockes dans un `Map<string, string>` global au niveau module. Quand un user charge ses overrides lors de `getOrCreateSession`, `setPromptOverride` ecrase les overrides existants pour TOUS les utilisateurs. Si deux utilisateurs ont des overrides differents pour le meme agent, le dernier a se connecter gagne.
- **Code concerne** :
```typescript
// global au module
const promptOverrides = new Map<string, string>();
// Lors du login de chaque user :
for (const ov of overrides) {
setPromptOverride(ov.agent_name, ov.prompt);
}
userId + agentName au lieu de agentName seul.packages/server/src/director/index.ts:314-316translateOutput fait un appel LLM supplementaire pour traduire la reponse en francais. Meme si la reponse est DEJA en francais (la plupart du temps, vu que les prompts systeme sont en francais), un appel LLM complet est fait. Cela ajoute ~2-10 secondes de latence et consomme des tokens a chaque requete.if (result.success && result.output) {
result.output = await this.translateOutput(result.output);
}
Le prompt de traduction dit "Si le texte est deja en francais, renvoie-le tel quel", mais le modele doit quand meme etre appele pour le determiner.
packages/server/src/index.ts (tous les endpoints .get/.post/.patch/.delete)private: true dans nginx, ce qui limite l'exposition, mais tout client sur le reseau local peut abuser.packages/server/src/tools/client.ts:213-218writeFile utilise un heredoc bash << 'ULIAS_EOF' pour ecrire le contenu. Si le contenu du fichier contient la chaine ULIAS_EOF sur une ligne seule, le heredoc se ferme prematurement et le reste du contenu est interprete comme une commande bash.const escaped = content.replace(/'/g, "'\\''");
const result = await this.exec(instance,
`cat > "${path}" << 'ULIAS_EOF'\n${escaped}\nULIAS_EOF`
);
ULIAS_EOF en debut de ligne.packages/server/src/tools/response-cache.ts:23-37hashKey genere un hash 32-bit a partir de model + systemPrompt.slice(0,200) + lastUserMessage. Le hash 32-bit a un espace de ~4 milliards de valeurs, mais avec seulement 200 chars du system prompt, deux agents avec des prompts commencant par les memes 200 caracteres et recevant le meme message utilisateur auront le meme hash -- et donc la meme reponse cachee.const raw = `${model || 'default'}::${systemPrompt.slice(0, 200)}::${lastUser}`;
let hash = 0;
for (let i = 0; i < raw.length; i++) {
const chr = raw.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0; // 32-bit int
}
return `cache_${hash}`;
'Supabase' vs client.ts utilise .id (BUG CERTAIN)