Scope: close the findings still open after Sprint 1/2/3. Branch dev.
Validation : bun run typecheck (14 pkg) + cargo check desktop + cargo check mobile → tous verts.
Aucun commit fait — à revoir avant git commit.
| # | Item | Statut |
|---|---|---|
| 1 | desktop export_types dead code |
NO-OP — déjà propre (fn utilisée ligne 323 + test) |
| 2 | 40 warnings mobile | FIXED — 40 → 0 |
| 3 | S2.A2 deep-link providerID allowlist | FIXED |
| 4 | A.12 _ownedChildPid stale |
FIXED |
| 5 | A.17 stderr ring buffer 4096 B | FIXED BY a182f0559 (déjà à 16384) |
| 6 | S1.L2 markdown cache 200 entries | FIXED (cap 50 + TTL 60 s) |
| 7 | S1.L3 session-prefetch cache | FIXED (LRU cap 100) |
| 8 | S2.V2 embedding response validation | FIXED (Zod + dim check) |
| 9 | S2.V1 RPC worker ID reuse | FIXED (UUID + timeout 30 s + delete-before-resolve) |
| 10 | A.5 runtime permissions Android | VERIFIED — déjà correct (MainActivity + notifications.ts + graceful fallback) |
| 11 | A.6 Promise.all sans abort | FIXED BY bbd5cfd39 |
| 12 | I9 Thermal JNI Android | FIXED (impl JNI complète cfg-gated android, fallback "nominal") |
| 13 | Deferred mobile work | SKIP (scope) |
packages/desktop/src-tauri/src/lib.rs:492 : la fn est utilisée ligne 323 (dans setup()) et dans test_export_types. cargo check desktop ne remonte aucun warning. Finding obsolète.
Test manuel : cargo check -p opencode-desktop → 0 warning.
Cause : #[tauri::command] dans speech.rs, kokoro/, parakeet/, fetch_private_server sont enregistrés uniquement sous #[cfg(target_os="android")] dans invoke_handler!. Sur cargo check hôte Windows, le compilateur ne voit aucun appelant → 40 warnings dead_code.
Correctif :
speech.rs,kokoro/engine.rs,parakeet/engine.rs→#![allow(dead_code)]inner attr au niveau module, avec commentaire "cross-cfg API surface".fetch_private_server→#[allow(dead_code)]+ commentaire.let mut builder→#[cfg_attr(not(target_os = "android"), allow(unused_mut))](mut utilisé seulement dans bloc android).
Test manuel : cd packages/mobile/src-tauri && cargo check → 0 warning.
Risque : nul sur cible Android (les fonctions sont activement appelées). Sur hôte, les vrais bugs (type mismatch, trait unimplemented) restent signalés car seul dead_code est silencé, pas tout.
Fichier : packages/app/src/pages/layout.tsx:1366-1390.
Cause : parseOAuthCallbackDeepLink valide la forme (/^[a-z0-9][a-z0-9_-]{0,63}$/i) mais pas l'identité. Une URL opencode://oauth/callback?providerID=attacker&code=xxx déclenchait dispatchEvent quel que soit l'ID.
Correctif : au dispatch, on construit une allowlist = providers.all() (registry live) ∪ popularProviders (fallback first-launch) et on continue + console.warn si inconnu. Garde la compat avec les custom providers (tant qu'ils sont enregistrés).
Test manuel :
- Construire un deep-link
opencode://oauth/callback?providerID=foobar&code=xxx. L'ouvrir. Vérifierconsole.warn+ aucune dialog déclenchée. - Avec
providerID=anthropic(présent danspopularProviders), dispatch normal.
Risque : un provider dynamique chargé tardivement (après handleDeepLinks drain) peut rater son callback. Mitigation : popularProviders couvre les 9 principaux, les custom providers sont chargés au démarrage via provider.list() avant le drain des deep-links pendants.
Fichier : packages/opencode/src/local-llm-server/index.ts:615-623.
Cause : sur exit naturel du child llama-server (OOM, segfault, kill externe), le .then() qui annule le stderrReader laissait _ownedChildPid pointer sur un PID réutilisable par l'OS.
Correctif : reset de _ownedChildPid = null dans le handler child.exited.then(), gardé par if (_ownedChildPid === child.pid) pour éviter de marcher sur un spawn de remplacement (race avec ensureCorrectModel).
Test manuel :
- Démarrer le serveur local.
taskkill /F /PID <llama-server>pendant une inférence.- Vérifier que
_ownedChildPidretourne à null (via log) et qu'un nouveau ensureRunning démarre un child propre.
Risque : faible. La garde === évite la race la plus évidente.
STDERR_BUFFER_SIZE = 16384 déjà en place (ligne 32). Le finding est obsolète depuis le commit initial a182f0559.
Fichier : packages/ui/src/components/markdown.tsx.
Correctif :
max = 200→max = 50.- Ajout
TTL_MS = 60_000et champat: numberpar entrée. - Nouvelle fn
cacheGet()qui drop les entrées expirées à la lecture. touch()purge les entrées > TTL à chaque écriture (itération par ordre d'insertion = ordre d'âge, on s'arrête au premier non-expiré).
Test manuel :
- Streamer une conversation avec beaucoup de blocks markdown.
- Snapshot heap : RSS plafonne ~50 MB (vs 200 MB avant).
- Laisser la page idle 90 s, déclencher un render → le cache se purge.
Risque : rerender cost sur chunk déjà vu > 60s. Shiki highlight coûte ~1-5 ms pour un block 1 kB, acceptable.
Fichier : packages/app/src/context/global-sync/session-prefetch.ts.
Correctif : ajout CACHE_MAX = 100, setSessionPrefetch fait delete+set (MRU) puis évince tant que size > CACHE_MAX (par ordre FIFO/LRU). rev counter préservé pour invalider les tasks en vol.
Test manuel :
bun test src/context/global-sync/session-prefetch.test.ts→ 5/5 ok (déjà vérifié).- Scénario : ouvrir 120 sessions différentes sans fermer le projet. Vérifier que
cache.size === 100(via devtools).
Risque : nul, la sémantique existante (hit/miss) n'est pas modifiée, juste bornée.
Fichier : packages/opencode/src/rag/embed.ts.
Correctif :
- Schema Zod
VectorSchema = z.array(z.number().finite()).min(1). - Helper
assertVector(vec, expected?)qui throw sur shape invalide ou dim mismatch. generateEmbeddingvalide le vecteur retourné contreconfig.dimensions.generateEmbeddingsvalide la longueur du batch + chaque vecteur.
Test manuel :
- Mock un endpoint
/v1/embeddingsqui renvoie{embedding: ["a", "b"]}→ throw "invalid vector shape". - Mock un endpoint qui renvoie dim=1024 quand on attend 1536 → throw "dimension mismatch".
Risque : faible. Les providers honnêtes (OpenAI/Google) renvoient toujours une shape conforme.
Fichier : packages/opencode/src/util/rpc.ts.
Correctif :
- Compteur
idremplacé parcrypto.randomUUID()(pas de réutilisation possible). pendingtypéMap<string, Pending>avec{ resolve, reject, timer }.- Timeout 30 s par call — reject + cleanup du slot.
deleteavantresolvedans le handler onmessage.
Test manuel :
- Worker qui met 31 s à répondre → Promise reject avec "rpc: call X timed out after 30000ms".
- Worker qui répond normalement → unchanged behavior.
Risque : si un call RPC légitime dépasse 30 s (ex: indexing massif), il timeout. À surveiller dans les logs post-déploiement ; augmenter à 60 s si besoin. BM25 indexing est la seule call potentielle > 10 s.
Rien à changer.
- POST_NOTIFICATIONS : requis à runtime dans
MainActivity.kt:41-48(Kotlin) etpackages/mobile/src/notifications.ts:26-30(JS). Double couverture. - RECORD_AUDIO : obtenu via WebView
navigator.mediaDevices.getUserMedia()dansuse-speech.ts:48— le WebView Android délègue la demande au système. Le try/catch ligne 83 capture les refus et dispatchstt-start-failed+console.error. - Fallback gracieux : sur refus, l'event
stt-start-failedest dispatché. Aucun subscriber pour l'instant → la fallback n'avance pas au-delà du log. Amélioration cosmétique possible mais non bloquante.
Test manuel :
- Révoquer micro dans les settings Android → cliquer record STT → console.error + UI reset.
- Révoquer notifications →
requestPermission()relance la dialog ou échoue gracefully.
Commit confirmé : fix(A.6): wrap setup Promise.all with AbortSignal via raceAbort.
Fichier : packages/mobile/src-tauri/src/lib.rs:87-170 + Cargo.toml (deps Android).
Correctif :
- Ajout
jni = "0.21"+ndk-context = "0.1"dans[target.'cfg(target_os = "android")'.dependencies]. - Nouvelle fn
query_thermal_status_jni():ndk_context::android_context()→JavaVM::from_raw→attach_current_thread→getSystemService("power")→getCurrentThermalStatus(): I. - Mapping via
thermal_code_to_label: 0-1 nominal, 2 fair, 3 serious, 4+ critical. - Tout JNI error path retourne "nominal" + log.debug (pas de crash possible).
- Pas de listener — polling 30s côté TS reste en place (commentaire le mentionne).
Test manuel :
- Android build :
ORT_LIB_LOCATION=... bun tauri android build— compile attendue (build 5+ min, pas encore vérifié, à confirmer au prochain run). - Sur device,
adb shell dumpsys thermalservice→ forcer un overridecmd thermalservice override-status 3→get_thermal_statedoit retourner "serious".
Risque : le cargo check hôte ne valide PAS le code JNI (cfg-gated android). Un typo dans l'appel env.call_method passerait silencieusement. Si le build Android casse, revert Cargo.toml + restaurer le stub fn get_thermal_state() -> "nominal".
Points de vigilance :
JavaVM::from_rawestunsafe— le pointeurctx.vm()doit être unJavaVM*valide. ndk-context garantit ça pour le process lifetime.attach_current_threadretourne unAttachGuardqui détache automatiquement via Drop en fin de fn.- Signatures JNI :
"(Ljava/lang/String;)Ljava/lang/Object;"pourgetSystemService,"()I"pourgetCurrentThermalStatus()— vérifiées contre Android SDK 34.
Non traité cette passe, reste ouvert : Vim/alt-screen terminal, mouse tracking, virtual keybind row, neural voice clone (Kokoro voice clone). Scope trop large, chacun nécessite 1-3 jours dédiés.
$ bun run typecheck
Tasks: 14 successful, 14 total, Time: 8.458s
$ cd packages/desktop/src-tauri && cargo check
Finished `dev` profile — 0 warnings
$ cd packages/mobile/src-tauri && cargo check
Finished `dev` profile — 0 warnings
$ cd packages/app && bun test src/context/global-sync/session-prefetch.test.ts
5 pass, 0 fail, 12 expect() calls
packages/mobile/src-tauri/Cargo.toml(+jni, +ndk-context)packages/mobile/src-tauri/src/lib.rs(thermal JNI, allow(dead_code), cfg_attr unused_mut)packages/mobile/src-tauri/src/speech.rs(inner allow dead_code)packages/mobile/src-tauri/src/kokoro/engine.rs(inner allow dead_code)packages/mobile/src-tauri/src/parakeet/engine.rs(inner allow dead_code)packages/opencode/src/local-llm-server/index.ts(reset _ownedChildPid on exit)packages/opencode/src/rag/embed.ts(Zod validation embeddings)packages/opencode/src/util/rpc.ts(UUID + timeout + delete-before-resolve)packages/ui/src/components/markdown.tsx(cap 50 + TTL 60s)packages/app/src/context/global-sync/session-prefetch.ts(LRU cap 100)packages/app/src/pages/layout.tsx(providerID allowlist)
Proposition de découpage (un commit par thème) :
chore(mobile): silence 40 cross-cfg dead_code warnings on host checkfix(llm): reset _ownedChildPid on natural child exit (A.12)perf(markdown): cap cache to 50 entries + 60s TTL (S1.L2)perf(sync): bound session-prefetch cache to 100 LRU entries (S1.L3)sec(deep-link): validate oauth providerID against provider registry (S2.A2)sec(rag): validate embedding responses with Zod (S2.V2)sec(rpc): UUID ids + 30s timeout + delete-before-resolve (S2.V1)feat(mobile/thermal): wire PowerManager.getCurrentThermalStatus via JNI (I9)
Deuxième passe de nettoyage ciblée sur les 7 findings persistants de
SECURITY_AUDIT.md / AUDIT_REPORT.md. Validation : bun run typecheck
(14 pkg ok), cargo check --release desktop (ok), cargo check mobile host
(ok), cargo clippy --no-deps desktop + mobile (0 warning introduit).
Aucun commit fait.
| # | Item | Statut |
|---|---|---|
| 1 | S2.A3 unsafe env::set_var SAFETY comments |
ENRICHED — déjà présents, reformulés en SAFETY: capitalisé + rationale thread-safety explicite |
| 2 | S2.A4 Windows registry u16 alignment |
FIXED — cast alignée via u16::from_le_bytes sur chunks_exact(2), plus aucun cast non-aligné |
| 3 | S3.A1 innerHTML lint rule | DOCUMENTED — .eslintrc.restrict.cjs créé (no-restricted-syntax), 4 call sites annotés eslint-disable-next-line avec justification. Pas d'ESLint en CI → rule opt-in |
| 4 | S3.A2 deep-link directory validation | FIXED — isSafeDirectory exige maintenant un chemin absolu (POSIX /, Windows X:\/X:/, UNC \\). Les chemins relatifs ou bare names sont rejetés |
| 5 | S2.L1 SSE heartbeat double-stop race | NO-OP — un flag let done = false protège déjà stop() (event.ts:36). Pas de modification nécessaire |
| 6 | S2.L2 Terminal focus microbursts | NO-OP — createEffect(on(...)) appelle onCleanup(stop) avant chaque ré-exécution : Solid cancel rAF+timers automatiquement. Pas de race observable |
| 7 | A.11 Tauri command input guards | FIXED — path traversal colmaté sur tts_save/delete_voice_clone (desktop + mobile), bounds sur tts_speak, kokoro_synthesize, stt_transcribe, parse_markdown_command, wsl_path, fetch_private_server, write_debug_log, charset guard sur check_app_exists/resolve_app_path |
S2.A3 — SAFETY comments (ENRICHED)
Fichier : packages/desktop/src-tauri/src/main.rs:14,70. Commentaires
existants (// Safety:) reformulés en // SAFETY: (convention Rust) avec
rationale détaillée : env::set_var n'est unsound qu'en cas d'accès
concurrent à l'environnement (libc setenv/getenv non thread-safe), et
les deux call sites s'exécutent en haut de main() avant tout spawn tokio
/ rayon / plugin.
S2.A4 — Registry alignment (FIXED)
Fichier : packages/desktop/src-tauri/src/os/windows.rs:200-210.
Remplacement du std::slice::from_raw_parts(data.as_ptr().cast::<u16>(), …)
(UB sur strict-alignment, Windows-on-ARM ou sanitizer) par une boucle
chunks_exact(2) + u16::from_le_bytes([a, b]) qui est 1-byte aligned
par construction. Le unsafe autour du bloc disparaît (l'unsafe restant
entoure seulement les appels FFI RegGetValueW).
Test manuel : cargo check --release desktop → ok ; cargo clippy --no-deps
→ aucun nouveau warning. Fonctionnellement identique (LE order imposé par
Registry API).
S3.A1 — innerHTML lint rule (DOCUMENTED)
Aucun eslint.config.* ou .eslintrc* n'existe dans le repo (confirmé par
Glob **/eslint.config.* et **/.eslintrc*). Création de
.eslintrc.restrict.cjs à la racine avec deux rules no-restricted-syntax :
une pour x.innerHTML = …, une pour JSX innerHTML={…}. Le fichier
documente les 7 call sites vettés et sert de living-doc même sans CI
ESLint.
4 call sites annotés (les 3 de l'audit + le JSX dans content-bash.tsx) :
packages/ui/src/components/markdown.tsx:95,329packages/app/src/components/file-tree.tsx:99packages/web/src/components/share/content-bash.tsx:51-52
Chaque annotation précise la provenance du HTML (icon registry statique, Shiki sanitized, DOM outerHTML) pour passer l'audit.
Risque : la règle ne s'active que si quelqu'un branche ESLint sur le repo. C'est un filet documentaire pour un futur setup. Aucune régression possible ici (fichier isolé, pas chargé par le runtime).
S3.A2 — Deep-link directory (FIXED)
Fichier : packages/app/src/pages/layout/deep-links.ts:17-40.
Ajout d'une heuristique isAbsolutePath(d) appelée depuis
isSafeDirectory : accepte /... (POSIX), X:\/X:/ (Windows drive),
\\server\share (UNC). Tout le reste (relatif, bare name) est refusé.
L'appel à GET /project pour croiser les roots connus a été évalué mais
écarté : parseDeepLink est synchrone, appelé au drain des deep-links
pending avant que l'app n'ait forcément chargé /project — cela
introduirait une dépendance réseau dans un hot path startup. L'absolute
path check est la barrière la plus rentable et la moins risquée.
Tests manuels :
opencode://open-project?directory=../../etc→ rejeté (log silencieux).opencode://open-project?directory=/tmp/demo→ accepté.opencode://open-project?directory=C:\Users\me\project→ accepté.opencode://open-project?directory=relative/path→ rejeté.
Risque : les tests unitaires existants (helpers.test.ts) utilisent
tous des chemins absolus (/tmp/demo, /a, /b, /c), donc aucune
régression attendue. Un utilisateur qui aurait construit un deep-link avec
un chemin relatif (improbable, l'UI n'en génère jamais) devra utiliser
la forme absolue.
S2.L1 — SSE heartbeat (NO-OP)
Vérifié packages/opencode/src/server/routes/event.ts:36-62 : un flag
let done = false est déjà présent et gate stop(). stop() est câblé
sur 3 chemins (Bus.InstanceDisposed, stream.onAbort, finally de la boucle
SSE) et chacun est idempotent par construction. Le finding est obsolète.
S2.L2 — Terminal focus microbursts (NO-OP)
Vérifié packages/app/src/pages/session/terminal-panel.tsx:168-206 : la
fonction focus(id) retourne un cleanup qui cancelAnimationFrame(frame)
et clearTimeout(timer) pour tous les timers enregistrés. Le cleanup est
appelé par onCleanup(stop) à l'intérieur de createEffect(on(...)), ce
qui garantit que Solid l'exécute avant chaque ré-exécution de l'effect.
Pas de race observable : un switch rapide de terminal actif annule bien
l'ancien rAF + timers avant de créer les nouveaux. Aucun correctif
nécessaire.
A.11 — Tauri command input guards (FIXED)
Inventaire des 42 #[tauri::command] (desktop + mobile) et revue par
catégorie d'entrée :
| Commande | Risque | Guard ajouté |
|---|---|---|
tts_save_voice_clone(name, audio_base64) (desktop + mobile) |
path traversal via name dans {dir}/{name}.wav |
validate_voice_clone_name (charset + traversal) + bound 32 MiB sur audio_base64 |
tts_delete_voice_clone(name) (desktop + mobile) |
path traversal — idem fs::remove_file |
validate_voice_clone_name |
tts_speak(text, voice?) (desktop) |
DoS via text multi-MB | bound 1 MiB + charset guard sur voice (path-sep, null) |
kokoro_synthesize(text, voice, speed) (desktop + mobile) |
idem + speed NaN | bound 1 MiB + voice charset + speed.is_finite() && 0.1..=4.0 |
stt_transcribe(audio_base64) (desktop + mobile) |
DoS | bound 64 MiB |
parse_markdown_command(markdown) (desktop) |
DoS | bound 8 MiB |
wsl_path(path) (desktop) |
null byte / CRLF injection dans args | bound 4096 + refus \r/\n |
check_app_exists(app_name) / resolve_app_path(app_name) (desktop) |
registry lookup / which avec entrée non validée |
validate_open_app_name appliqué (charset alias-only) |
fetch_private_server(url, body?) (mobile) |
DoS via URL/body énorme | bound url 4096 + CRLF refus + body bound 16 MiB |
write_debug_log(message) (mobile) |
flood log file | truncate à 8192 B (char-boundary safe) |
Autres (set_remote_credentials, set_default_server_url, download_model, load_llm_model, delete_model, open_path, generate_llm, …) |
— | déjà validés (validate_filename / validate_url / validate_open_target) ou types scalaires |
Validators ajoutés dans :
packages/desktop/src-tauri/src/validate.rs:validate_voice_clone_name,validate_bounded_textpackages/mobile/src-tauri/src/validate.rs:validate_voice_clone_name,validate_bounded_text(le modulevalidatemobile est maintenant cross-target puisquespeech.rsest host-compilé ;validate_urlreste cfg-gated android car elle utilise le crateurldéclaré android-only dans Cargo.toml)
Tests manuels :
tts_save_voice_clone({name: "../../../etc/passwd", ...})→Err("voice clone name contains forbidden characters").tts_save_voice_clone({name: "voice-01_clean.A", ...})→ ok (charset alphanumérique +.- _).kokoro_synthesize({text: "x".repeat(2_000_000), ...})→Err("kokoro text exceeds 1048576 byte limit").kokoro_synthesize({text: "hi", voice: "af_heart", speed: f32::NAN})→Err("speed out of range").wsl_path({path: "path\nwith\nnewline"})→Err("wsl path contains control characters").check_app_exists({app_name: "C:\\Windows\\System32\\cmd.exe"})→false(validate_open_app_name refuse les séparateurs).
Risques :
validate_voice_clone_nameimpose[a-zA-Z0-9_][a-zA-Z0-9\-_. ]*. Les noms existants (créés avant ce correctif) qui incluraient des caractères hors charset (ex: émoji, accents) deviendront non-supprimables via l'UI. Atténuation : 128 B max + charset ASCII only ; on accepte l'incompat mineure pour fermer le path traversal.validate_open_app_nameapplique[a-zA-Z0-9_\- . ]{1,64}. Les alias custom comme"vscode-insiders","Google Chrome","iTerm"passent (couverts par les tests unitaires existants dansvalidate.rs:178-185).- Bounds à 1 / 8 / 32 / 64 MiB : tous très au-dessus des usages réels (messages UI ~KiB, audio voice ~MiB). Pas de régression UX attendue.
$ bun run typecheck # 14/14 packages OK
$ cd packages/desktop/src-tauri && cargo check --release # OK
$ cd packages/mobile/src-tauri && cargo check # OK
$ cd packages/desktop/src-tauri && cargo clippy --no-deps # 0 new warning
$ cd packages/mobile/src-tauri && cargo clippy --no-deps # 0 new warning
Aucun commit fait — à revoir avant git commit.