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

This commit is contained in:
nathaninline committed 2026-07-22 11:57:56 +02:00
1 parent cd570c5c5e
commit 02d164899e
6 files changed
+213 -60

No files matched your search

+61 -38
View File
@@ -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()
}
}
+24 -6
View File
@@ -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
+6 -1
View File
@@ -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)
+65 -1
View File
@@ -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` : "<hex> <nom>").
// 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()
+4 -9
View File
@@ -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 <clé>" header (cas normal) or as a ?key=<clé>
// 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 <clé>" header. La comparaison est à temps constant.
// PAS de repli ?key=<clé> 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
}
+53 -5
View File
@@ -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