From 02d164899e30989af6c8196ba622b663278335fc Mon Sep 17 00:00:00 2001 From: nathaninline Date: Wed, 22 Jul 2026 11:57:56 +0200 Subject: [PATCH] fix revue de code : reset pendant generation (epoch guard, plus de messages fantomes), message user persiste des l'envoi, cle de pilotage refusee en query string (fuite logs/historique), verif SHA256SUMS dans jean update, edit preserve les permissions du fichier, conflit de port gere sous Windows (netstat/taskkill), /api/update/apply renvoie 500 en echec --- internal/jean/chat_conversation.go | 99 +++++++++++++++---------- internal/jean/chat_conversation_test.go | 30 ++++++-- internal/jean/chat_tools.go | 7 +- internal/jean/sys_update.go | 66 ++++++++++++++++- internal/jean/web_auth.go | 13 +--- internal/jean/web_server.go | 58 +++++++++++++-- 6 files changed, 213 insertions(+), 60 deletions(-) diff --git a/internal/jean/chat_conversation.go b/internal/jean/chat_conversation.go index 71a90d9..37b4b9f 100644 --- a/internal/jean/chat_conversation.go +++ b/internal/jean/chat_conversation.go @@ -101,8 +101,15 @@ func (c *Conversation) persist() { } // appendDelta journalise un événement d'affichage et réveille les abonnés. -func (c *Conversation) appendDelta(delta map[string]any) { +// epoch est celui capturé au début du tour : si un Reset est passé entre-temps, +// l'événement appartient à l'ancienne conversation et est jeté (sinon il +// polluerait le journal tout neuf avec des Seq repartis de zéro). +func (c *Conversation) appendDelta(epoch int, delta map[string]any) { c.mu.Lock() + if c.epoch != epoch { + c.mu.Unlock() + return + } c.Seq++ c.Log = append(c.Log, LogEvent{Seq: c.Seq, TS: time.Now().UnixMilli(), Delta: delta}) if len(c.Log) > maxLogEvents { @@ -138,26 +145,37 @@ func (c *Conversation) StartTurn(text string, caps Caps, temperature float64) er ctx, cancel := context.WithCancel(context.Background()) c.cancel = cancel c.Messages = append(c.Messages, Message{Role: "user", Content: text}) + epoch := c.epoch c.mu.Unlock() - // Borne de tour + bulle utilisateur (rejouables). - c.appendDelta(map[string]any{"user": text}) + // Borne de tour + bulle utilisateur (rejouables). Persistée tout de suite : + // si le process meurt en pleine génération (crash, restart après MAJ), le + // message de l'utilisateur survit au lieu de disparaître avec le tour. + c.appendDelta(epoch, map[string]any{"user": text}) + c.persist() if temperature == 0 { temperature = 0.7 } - go c.generate(ctx, caps, temperature) + go c.generate(ctx, caps, temperature, epoch) return nil } // generate exécute un tour complet et journalise chaque événement. Détaché : la // fermeture du navigateur n'a aucun effet ici, seul /stop (cancel) l'interrompt. -func (c *Conversation) generate(ctx context.Context, caps Caps, temperature float64) { +// epoch est capturé au StartTurn : si un Reset survient pendant la génération, +// tout ce que ce tour produirait ensuite (deltas, messages, persistance) est +// abandonné au lieu de ressusciter des morceaux de l'ancienne conversation. +func (c *Conversation) generate(ctx context.Context, caps Caps, temperature float64, epoch int) { defer func() { c.mu.Lock() c.Generating = false c.cancel = nil + stale := c.epoch != epoch c.mu.Unlock() - c.appendDelta(map[string]any{"turn_done": true}) + if stale { + return // Reset pendant le tour : Reset a déjà persisté l'état vide + } + c.appendDelta(epoch, map[string]any{"turn_done": true}) c.persist() }() @@ -172,9 +190,11 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa if compacted, changed := MaybeCompact(ctx, msgs, caps, ctxUsed); changed { msgs = compacted c.mu.Lock() - c.Messages = compacted + if c.epoch == epoch { + c.Messages = compacted + } c.mu.Unlock() - c.appendDelta(map[string]any{"compacted": true}) + c.appendDelta(epoch, map[string]any{"compacted": true}) } // Prompt système personnalisé (UI → /api/sysprompt, fichier côté serveur). @@ -189,9 +209,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool { switch { case ev.Err != nil: - c.appendDelta(map[string]any{"error": ev.Err.Error()}) + c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()}) case ev.ToolUsed != nil: - c.appendDelta(map[string]any{"tool_used": map[string]any{ + c.appendDelta(epoch, map[string]any{"tool_used": map[string]any{ "name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label, "result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing, }}) @@ -200,28 +220,33 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // et la décision de compactage au tour suivant. if ev.Stats.PromptTokensTotal > 0 { c.mu.Lock() - c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens + if c.epoch == epoch { + c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens + } c.mu.Unlock() } - c.appendDelta(map[string]any{"stats": ev.Stats}) + c.appendDelta(epoch, map[string]any{"stats": ev.Stats}) case ev.DropReasoning: - c.appendDelta(map[string]any{"drop_reasoning": true}) + c.appendDelta(epoch, map[string]any{"drop_reasoning": true}) case ev.Reasoning != "": - c.appendDelta(map[string]any{"reasoning_content": ev.Reasoning}) + c.appendDelta(epoch, map[string]any{"reasoning_content": ev.Reasoning}) case ev.Content != "": content.WriteString(ev.Content) - c.appendDelta(map[string]any{"content": ev.Content}) + c.appendDelta(epoch, map[string]any{"content": ev.Content}) } return true // génération détachée : on ne s'interrompt jamais sur un abonné }) // Persiste la vue modèle : messages d'outils (assistant tool_calls + résultats) // PUIS la réponse finale — même ordre que l'ancien client, pour que le modèle - // garde la trace de ce qu'il a fait. + // garde la trace de ce qu'il a fait. Sauf si un Reset est passé entre-temps : + // la nouvelle conversation vide ne doit pas hériter de la fin de l'ancienne. c.mu.Lock() - c.Messages = append(c.Messages, extra...) - if s := content.String(); strings.TrimSpace(s) != "" { - c.Messages = append(c.Messages, Message{Role: "assistant", Content: s}) + if c.epoch == epoch { + c.Messages = append(c.Messages, extra...) + if s := content.String(); strings.TrimSpace(s) != "" { + c.Messages = append(c.Messages, Message{Role: "assistant", Content: s}) + } } c.mu.Unlock() } @@ -407,32 +432,30 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st c.mu.Lock() continue } - sent := false + // Copie des événements en attente SOUS verrou, émission HORS verrou : on + // n'itère jamais sur c.Log pendant que la génération peut y écrire ou que + // la troncature (maxLogEvents) peut le déplacer. + var pending []LogEvent for _, ev := range c.Log { - if ev.Seq <= last { - continue + if ev.Seq > last { + pending = append(pending, ev) } - last = ev.Seq - delta := ev.Delta - ts := ev.TS - c.mu.Unlock() - out := map[string]any{"seq": ev.Seq, "ts": ts} - for k, v := range delta { + } + if len(pending) == 0 { + c.cond.Wait() + continue + } + last = pending[len(pending)-1].Seq + c.mu.Unlock() + for _, ev := range pending { + out := map[string]any{"seq": ev.Seq, "ts": ev.TS} + for k, v := range ev.Delta { out[k] = v } if !emit(out) { return } - c.mu.Lock() - sent = true - if ctx.Err() != nil { - c.mu.Unlock() - return - } } - if sent { - continue - } - c.cond.Wait() + c.mu.Lock() } } diff --git a/internal/jean/chat_conversation_test.go b/internal/jean/chat_conversation_test.go index 6d5ec1a..c263ca7 100644 --- a/internal/jean/chat_conversation_test.go +++ b/internal/jean/chat_conversation_test.go @@ -21,8 +21,8 @@ func newTestConv() *Conversation { // et se terminer quand le contexte (la connexion) est annulé. func TestSubscribeReplayAndLive(t *testing.T) { c := newTestConv() - c.appendDelta(map[string]any{"user": "salut"}) - c.appendDelta(map[string]any{"content": "bon"}) + c.appendDelta(c.epoch, map[string]any{"user": "salut"}) + c.appendDelta(c.epoch, map[string]any{"content": "bon"}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -38,15 +38,15 @@ func TestSubscribeReplayAndLive(t *testing.T) { waitSeq(t, got, 1) waitSeq(t, got, 2) // Un événement en direct après abonnement. - c.appendDelta(map[string]any{"content": "jour"}) + c.appendDelta(c.epoch, map[string]any{"content": "jour"}) waitSeq(t, got, 3) } // Un abonné qui démarre à from=N ne reçoit que ce qui est plus récent que N. func TestSubscribeFromOffset(t *testing.T) { c := newTestConv() - c.appendDelta(map[string]any{"user": "a"}) - c.appendDelta(map[string]any{"user": "b"}) + c.appendDelta(c.epoch, map[string]any{"user": "a"}) + c.appendDelta(c.epoch, map[string]any{"user": "b"}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -64,7 +64,7 @@ func TestSubscribeFromOffset(t *testing.T) { // Reset bump l'epoch et pousse un {reset:true} aux abonnés, qui repartent de 0. func TestResetNotifiesSubscribers(t *testing.T) { c := newTestConv() - c.appendDelta(map[string]any{"user": "x"}) + c.appendDelta(c.epoch, map[string]any{"user": "x"}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() resetSeen := make(chan bool, 4) @@ -87,6 +87,24 @@ func TestResetNotifiesSubscribers(t *testing.T) { } } +// Un Reset survenu pendant un tour invalide l'epoch : les deltas du tour en +// cours sont jetés au lieu de polluer la nouvelle conversation (Seq repartis +// de zéro, messages fantômes). +func TestAppendDeltaStaleEpochDropped(t *testing.T) { + c := newTestConv() + epoch := c.epoch // capturé comme au début d'un tour + c.appendDelta(epoch, map[string]any{"user": "avant"}) + c.Reset() + c.appendDelta(epoch, map[string]any{"content": "fantôme"}) // tour périmé + if c.Seq != 0 || len(c.Log) != 0 { + t.Fatalf("delta périmé accepté après Reset: Seq=%d len(Log)=%d", c.Seq, len(c.Log)) + } + c.appendDelta(c.epoch, map[string]any{"user": "nouveau"}) // nouveau tour + if c.Seq != 1 || len(c.Log) != 1 { + t.Fatalf("delta du nouvel epoch refusé: Seq=%d len(Log)=%d", c.Seq, len(c.Log)) + } +} + // Les handlers de contrôle répondent en JSON sans dépendre du modèle. func TestChatControlHandlers(t *testing.T) { // reset → conversation vide diff --git a/internal/jean/chat_tools.go b/internal/jean/chat_tools.go index 530a9e8..f99d381 100644 --- a/internal/jean/chat_tools.go +++ b/internal/jean/chat_tools.go @@ -180,7 +180,12 @@ func fileEdit(path, oldText, newText string) string { return fmt.Sprintf("[erreur] old apparaît %d fois — ajoute du contexte pour le rendre unique", n) } updated := strings.Replace(content, oldText, newText, 1) - if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + // Préserve les permissions d'origine (un script 0755 doit rester exécutable). + mode := os.FileMode(0o644) + if fi, err := os.Stat(path); err == nil { + mode = fi.Mode() + } + if err := os.WriteFile(path, []byte(updated), mode); err != nil { return "[erreur] " + err.Error() } return fmt.Sprintf("[ok] %s modifié (1 remplacement)", path) diff --git a/internal/jean/sys_update.go b/internal/jean/sys_update.go index 5c7e462..0b68c31 100644 --- a/internal/jean/sys_update.go +++ b/internal/jean/sys_update.go @@ -1,6 +1,8 @@ package jean import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -137,6 +139,10 @@ func applyUpdate() (string, error) { if err := downloadTo(url, tmp); err != nil { return "", fmt.Errorf("téléchargement : %w", err) } + if err := verifyChecksum(rel, want, tmp); err != nil { + os.Remove(tmp) + return "", err + } mode := os.FileMode(0o755) if fi, err := os.Stat(exe); err == nil { mode = fi.Mode() @@ -214,6 +220,63 @@ func downloadTo(url, dst string) error { return cErr } +// verifyChecksum vérifie le SHA-256 du binaire téléchargé contre le fichier +// SHA256SUMS publié dans la release (format `sha256sum` : " "). +// Si la release n'en publie pas (anciennes versions), on ne vérifie rien — +// le contrôle de taille reste le seul garde-fou, comme avant. +func verifyChecksum(rel *ghRelease, assetName, path string) error { + var sumsURL string + for _, a := range rel.Assets { + switch a.Name { + case "SHA256SUMS", "SHA256SUMS.txt", "checksums.txt": + sumsURL = a.BrowserDownloadURL + } + } + if sumsURL == "" { + return nil + } + req, _ := http.NewRequest("GET", sumsURL, nil) + req.Header.Set("User-Agent", "jean-update") + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return fmt.Errorf("téléchargement des sommes de contrôle : %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("sommes de contrôle : GitHub a répondu %s", resp.Status) + } + b, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + if err != nil { + return err + } + want := "" + for _, line := range strings.Split(string(b), "\n") { + f := strings.Fields(line) + // sha256sum peut préfixer le nom de "*" (mode binaire). + if len(f) == 2 && strings.TrimPrefix(f[1], "*") == assetName { + want = strings.ToLower(f[0]) + break + } + } + if want == "" { + return fmt.Errorf("SHA256SUMS présent mais sans entrée pour %q — mise à jour annulée", assetName) + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + got := hex.EncodeToString(h.Sum(nil)) + if got != want { + return fmt.Errorf("somme SHA-256 invalide (%s reçu, %s attendu) — mise à jour annulée", got, want) + } + return nil +} + func fileSize(p string) int64 { if fi, err := os.Stat(p); err == nil { return fi.Size() @@ -270,7 +333,8 @@ func handleUpdateCheck(w http.ResponseWriter, r *http.Request) { func handleUpdateApply(w http.ResponseWriter, r *http.Request) { newVer, err := applyUpdate() if err != nil { - sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()}) + // 500 : un client script peut tester le statut HTTP ; l'UI, elle, lit le JSON. + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } restarting, msg := restartAfterUpdate() diff --git a/internal/jean/web_auth.go b/internal/jean/web_auth.go index 50c4da8..66013e8 100644 --- a/internal/jean/web_auth.go +++ b/internal/jean/web_auth.go @@ -53,10 +53,10 @@ func requireWebAuth(next http.HandlerFunc) http.HandlerFunc { } } -// checkBearer reports whether the request carries the expected key, either as an -// "Authorization: Bearer " header (cas normal) or as a ?key= -// query param (repli pour les clients qui ne peuvent pas poser d'en-tête, p.ex. -// une URL ouverte directement). La comparaison est à temps constant. +// checkBearer reports whether the request carries the expected key as an +// "Authorization: Bearer " header. La comparaison est à temps constant. +// PAS de repli ?key= en query string : une clé dans l'URL finit dans les +// logs des proxys (Caddy, Cloudflare), l'historique navigateur et les Referer. func checkBearer(r *http.Request, key string) bool { want := []byte(key) if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { @@ -65,11 +65,6 @@ func checkBearer(r *http.Request, key string) bool { return true } } - if q := r.URL.Query().Get("key"); q != "" { - if subtle.ConstantTimeCompare([]byte(q), want) == 1 { - return true - } - } return false } diff --git a/internal/jean/web_server.go b/internal/jean/web_server.go index 68b589e..121e059 100644 --- a/internal/jean/web_server.go +++ b/internal/jean/web_server.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "regexp" + "runtime" "strconv" "strings" "sync" @@ -142,8 +143,8 @@ func resolvePortConflict(port int) bool { fmt.Println(dim(" annulé.")) return false } - // SIGTERM d'abord, puis SIGKILL si le port ne se libère pas. - _ = exec.Command("kill", "-TERM", strconv.Itoa(pid)).Run() + // Arrêt poli d'abord, puis forcé si le port ne se libère pas. + killPid(pid, false) for i := 0; i < 15; i++ { time.Sleep(200 * time.Millisecond) if p, _ := pidOnPort(port); p == 0 { @@ -151,7 +152,7 @@ func resolvePortConflict(port int) bool { return true } } - _ = exec.Command("kill", "-KILL", strconv.Itoa(pid)).Run() + killPid(pid, true) time.Sleep(500 * time.Millisecond) if p, _ := pidOnPort(port); p != 0 { fmt.Printf("%s impossible de libérer le port %d (PID %d toujours présent)\n", red("[err]"), port, p) @@ -161,10 +162,47 @@ func resolvePortConflict(port int) bool { return true } +// killPid termine un process : kill TERM/KILL sous Unix, taskkill sous Windows +// (où il n'existe pas d'arrêt « poli » générique — taskkill sans /F échoue sur +// les process console, donc le second essai passe en forcé). +func killPid(pid int, force bool) { + if runtime.GOOS == "windows" { + args := []string{"/PID", strconv.Itoa(pid)} + if force { + args = append(args, "/F") + } + _ = exec.Command("taskkill", args...).Run() + return + } + sig := "-TERM" + if force { + sig = "-KILL" + } + _ = exec.Command("kill", sig, strconv.Itoa(pid)).Run() +} + // pidOnPort returns the PID and command name of the process listening on the -// given TCP port, via `ss` (Linux) with an `lsof` fallback. Returns 0 if none -// is found or if the tools can't see it (e.g. owned by another user). +// given TCP port, via `ss` (Linux) with an `lsof` fallback, or `netstat -ano` +// on Windows. Returns 0 if none is found or if the tools can't see it (e.g. +// owned by another user). func pidOnPort(port int) (int, string) { + if runtime.GOOS == "windows" { + // netstat -ano : " TCP 0.0.0.0:8090 0.0.0.0:0 LISTENING 1234" + out, err := exec.Command("netstat", "-ano", "-p", "tcp").Output() + if err != nil { + return 0, "" + } + suffix := ":" + strconv.Itoa(port) + for _, line := range strings.Split(string(out), "\n") { + f := strings.Fields(line) + if len(f) >= 5 && f[0] == "TCP" && strings.HasSuffix(f[1], suffix) && f[3] == "LISTENING" { + if pid, err := strconv.Atoi(f[4]); err == nil && pid > 0 { + return pid, processName(pid) + } + } + } + return 0, "" + } redir := regexp.MustCompile(`pid=(\d+)`) if out, err := exec.Command("ss", "-ltnHp", fmt.Sprintf("sport = :%d", port)).Output(); err == nil { if m := redir.FindStringSubmatch(string(out)); m != nil { @@ -184,6 +222,16 @@ func pidOnPort(port int) (int, string) { // processName returns a short command name for a PID, or "?" if unknown. func processName(pid int) string { + if runtime.GOOS == "windows" { + // tasklist CSV : "jean.exe","1234","Console","1","12 345 K" + out, err := exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH").Output() + if err == nil { + if f := strings.SplitN(strings.TrimSpace(string(out)), "\",\"", 2); len(f) == 2 { + return strings.TrimPrefix(f[0], "\"") + } + } + return "?" + } if b, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)); err == nil { if n := strings.TrimSpace(string(b)); n != "" { return n