From a8a016c1b80349cb08bc627d9bf3a38a2ab63c78 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 23 Jun 2026 19:41:15 +0200 Subject: [PATCH] =?UTF-8?q?v0.2.2=20:=20mise=20a=20jour=20de=20securite=20?= =?UTF-8?q?=E2=80=94=20boite=20noire=20E2E=20&=20relais=20aveugle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chiffrement E2E du chat navigateur<->agent (e2e.go) : le relais ne voit que de l'opaque, ancre par empreinte X25519 confirmee au `jean link`. - `jean link` refuse desormais tout chat en clair via le tunnel (403) ; seul /api/e2e/chat porte du contenu. Endpoint OpenAI clair desactive par defaut (JEAN_LINK_ALLOW_OAI=1 pour le reactiver). - Le portail web n'est plus servi par le relais : code livre par une origine hors de son controle, le relais devient un pur tube aveugle. --- chat.go | 4 +- e2e.go | 265 ++++++++++++++++++++++++++++++++++++++++++++++++++ link.go | 24 +++++ llm.go | 49 +++++++--- main.go | 2 +- serve.go | 13 +-- skills.go | 4 +- test_cmd.go | 3 +- tools.go | 12 ++- ui/index.html | 11 ++- web.go | 78 +++++++++------ 11 files changed, 403 insertions(+), 62 deletions(-) create mode 100644 e2e.go diff --git a/chat.go b/chat.go index 35075d8..fc4ceff 100644 --- a/chat.go +++ b/chat.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "context" "fmt" "os" "strings" @@ -65,7 +66,8 @@ func cmdChat(args []string) error { var stats *StatsEvent // Print the assistant prefix once; reasoning is shown inline with a tag. fmt.Print(cyan("jean") + " > ") - err := runChat(InjectSkills(msgs), 0.7, func(ev StreamEvent) bool { + caps := globalCaps() + err := runChat(context.Background(), InjectSkills(msgs, caps), 0.7, caps, func(ev StreamEvent) bool { switch { case ev.Err != nil: fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error())) diff --git a/e2e.go b/e2e.go new file mode 100644 index 0000000..691e2cb --- /dev/null +++ b/e2e.go @@ -0,0 +1,265 @@ +package main + +// e2e.go — chiffrement bout-en-bout du chat entre le NAVIGATEUR et CET agent, de +// sorte que le relais (ajean.link) ne voie que de l'opaque : « boîte noire ». +// +// Modèle : cet agent a une paire X25519 long-terme (clé privée locale, jamais +// transmise). Sa clé publique est publiée au relais et son EMPREINTE est affichée +// au `jean link` — l'utilisateur la confirme dans le portail, ce qui défait tout +// MITM du relais. Le navigateur dérive une racine R de son mot de passe (exportKey +// OPAQUE), la SCELLE vers la clé publique de l'agent (le relais ne peut pas +// l'ouvrir), puis chiffre/déchiffre le chat avec une clé dérivée de R. +// +// Aucune dépendance externe : crypto/ecdh (X25519) + AES-GCM de la stdlib. + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync" +) + +func e2eKeyPath() string { return filepath.Join(JeanHome(), ".e2e_key") } + +var ( + e2eOnce sync.Once + e2eKey *ecdh.PrivateKey + e2eErr error + // rCache : racines R déjà descellées, indexées par kid (évite de resceller/ouvrir + // à chaque requête). Protégé par mu. + e2eMu sync.Mutex + e2eRoots = map[string][]byte{} +) + +// e2ePrivateKey charge (ou crée puis persiste) la clé privée X25519 de l'agent. +func e2ePrivateKey() (*ecdh.PrivateKey, error) { + e2eOnce.Do(func() { + if b, err := os.ReadFile(e2eKeyPath()); err == nil { + raw, derr := hex.DecodeString(strings.TrimSpace(string(b))) + if derr == nil { + e2eKey, e2eErr = ecdh.X25519().NewPrivateKey(raw) + return + } + } + k, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + e2eErr = err + return + } + _ = os.MkdirAll(JeanHome(), 0o755) + if err := os.WriteFile(e2eKeyPath(), []byte(hex.EncodeToString(k.Bytes())+"\n"), 0o600); err != nil { + e2eErr = err + return + } + e2eKey = k + }) + return e2eKey, e2eErr +} + +// e2ePubHex retourne la clé publique X25519 de l'agent (hex), pour publication au relais. +func e2ePubHex() string { + k, err := e2ePrivateKey() + if err != nil { + return "" + } + return hex.EncodeToString(k.PublicKey().Bytes()) +} + +// e2eFingerprint retourne une empreinte lisible de la clé publique (à comparer +// dans le portail). Format : 8 groupes hex de 4 = SHA-256(pub) tronqué. +func e2eFingerprint() string { + k, err := e2ePrivateKey() + if err != nil { + return "" + } + h := sha256.Sum256(k.PublicKey().Bytes()) + s := hex.EncodeToString(h[:8]) // 16 hex + var parts []string + for i := 0; i < len(s); i += 4 { + parts = append(parts, strings.ToUpper(s[i:i+4])) + } + return strings.Join(parts, "-") +} + +// e2eOpenSeal ouvre une boîte scellée (ephPub32 || nonce12 || ct) chiffrée vers +// la clé publique de l'agent, et retourne le secret en clair (la racine R). +func e2eOpenSeal(blob []byte) ([]byte, error) { + if len(blob) < 32+12+16 { + return nil, fmt.Errorf("sceau trop court") + } + priv, err := e2ePrivateKey() + if err != nil { + return nil, err + } + ephPubBytes := blob[:32] + nonce := blob[32:44] + ct := blob[44:] + ephPub, err := ecdh.X25519().NewPublicKey(ephPubBytes) + if err != nil { + return nil, err + } + shared, err := priv.ECDH(ephPub) + if err != nil { + return nil, err + } + key := sealKey(shared, ephPubBytes, priv.PublicKey().Bytes()) + gcm, err := newGCM(key) + if err != nil { + return nil, err + } + return gcm.Open(nil, nonce, ct, nil) +} + +// sealKey dérive la clé AES de la boîte scellée (liée aux deux clés publiques). +func sealKey(shared, ephPub, agentPub []byte) []byte { + h := sha256.New() + h.Write(shared) + h.Write(ephPub) + h.Write(agentPub) + return h.Sum(nil) +} + +// chatKey dérive la clé AES-GCM du chat depuis la racine R. +func chatKey(r []byte) []byte { + h := sha256.New() + h.Write(r) + h.Write([]byte("ajean-chat-v1")) + return h.Sum(nil) +} + +// kidOf identifie une racine R (8 premiers octets de SHA-256(R), hex). +func kidOf(r []byte) string { + h := sha256.Sum256(r) + return hex.EncodeToString(h[:8]) +} + +func newGCM(key []byte) (cipher.AEAD, error) { + blk, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(blk) +} + +// e2eResolveRoot retourne la racine R pour ce kid, en la descellant si besoin et +// en la mémorisant. sealedR peut être vide si R est déjà connue. +func e2eResolveRoot(kid string, sealed []byte) ([]byte, error) { + e2eMu.Lock() + if r, ok := e2eRoots[kid]; ok { + e2eMu.Unlock() + return r, nil + } + e2eMu.Unlock() + if len(sealed) == 0 { + return nil, fmt.Errorf("racine inconnue et aucun sceau fourni") + } + r, err := e2eOpenSeal(sealed) + if err != nil { + return nil, err + } + if kidOf(r) != kid { + return nil, fmt.Errorf("kid ne correspond pas au sceau") + } + e2eMu.Lock() + e2eRoots[kid] = r + e2eMu.Unlock() + return r, nil +} + +// handleE2EChat : chat chiffré de bout en bout. Reçoit une enveloppe scellée, +// déchiffre la requête avec la clé dérivée de R, exécute le chat, et chiffre +// CHAQUE événement SSE. Le relais ne voit que de l'opaque. +func handleE2EChat(w http.ResponseWriter, r *http.Request) { + var env struct{ Kid, SealedR, Iv, Ct string } + if err := json.NewDecoder(r.Body).Decode(&env); err != nil { + http.Error(w, "e2e: enveloppe invalide", 400) + return + } + var sealed []byte + if env.SealedR != "" { + sealed, _ = base64.StdEncoding.DecodeString(env.SealedR) + } + root, err := e2eResolveRoot(env.Kid, sealed) + if err != nil { + http.Error(w, "e2e: "+err.Error(), 400) + return + } + gcm, err := newGCM(chatKey(root)) + if err != nil { + http.Error(w, "e2e: clé", 500) + return + } + iv, _ := base64.StdEncoding.DecodeString(env.Iv) + ct, _ := base64.StdEncoding.DecodeString(env.Ct) + plain, err := gcm.Open(nil, iv, ct, nil) + if err != nil { + http.Error(w, "e2e: déchiffrement", 400) + return + } + var body chatReq + if err := json.Unmarshal(plain, &body); err != nil { + http.Error(w, "e2e: requête", 400) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + flusher, _ := w.(http.Flusher) + emit := func(obj map[string]any) bool { + b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": obj}}}) + nonce := make([]byte, 12) + if _, err := rand.Read(nonce); err != nil { + return false + } + sealedEv := append(nonce, gcm.Seal(nil, nonce, b, nil)...) + if _, err := w.Write([]byte("data: " + base64.StdEncoding.EncodeToString(sealedEv) + "\n\n")); err != nil { + return false + } + if flusher != nil { + flusher.Flush() + } + return true + } + runChatStream(r.Context(), body, emit) +} + +// e2eSeal (côté agent : utilisé uniquement pour les tests) scelle un secret vers +// une clé publique X25519. La version navigateur est en WASM (wasmclient). +func e2eSeal(agentPub, secret []byte) ([]byte, error) { + pub, err := ecdh.X25519().NewPublicKey(agentPub) + if err != nil { + return nil, err + } + eph, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + shared, err := eph.ECDH(pub) + if err != nil { + return nil, err + } + key := sealKey(shared, eph.PublicKey().Bytes(), agentPub) + gcm, err := newGCM(key) + if err != nil { + return nil, err + } + nonce := make([]byte, 12) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + ct := gcm.Seal(nil, nonce, secret, nil) + out := append([]byte{}, eph.PublicKey().Bytes()...) + out = append(out, nonce...) + out = append(out, ct...) + return out, nil +} diff --git a/link.go b/link.go index 7daaa26..c485ac5 100644 --- a/link.go +++ b/link.go @@ -111,6 +111,13 @@ func cmdLink(args []string) error { fmt.Printf("%s connexion au relais %s …\n", cyan("[link]"), relayURL()) fmt.Printf(" (UI Jean + endpoint OpenAI servis dans le tunnel — pas besoin de 'jean web')\n") + // Empreinte E2E : à confirmer UNE FOIS dans le portail. Garantit que le chat + // est chiffré vers CET agent et pas vers un relais qui se ferait passer pour lui. + if fp := e2eFingerprint(); fp != "" { + fmt.Printf("\n%s empreinte E2E de cette machine :\n %s\n", green("[e2e]"), bold(fp)) + fmt.Printf(" Confirme-la dans le portail (Mon compte → serveur) pour activer la boîte noire.\n\n") + } + // On construit le handler une seule fois ; il est servi à travers chaque tunnel. handler := newLinkHandler() @@ -153,9 +160,23 @@ func newLinkHandler() http.Handler { lp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) { http.Error(w, "llama-server injoignable: "+e.Error(), http.StatusBadGateway) } + // Boîte noire : tout ce qui est servi ici transite par le relais. On REFUSE + // donc tout chemin qui ferait passer des prompts/réponses EN CLAIR. Seul le + // chat chiffré de bout en bout (/api/e2e/chat) est autorisé à porter du contenu. + oaiAllowed := os.Getenv("JEAN_LINK_ALLOW_OAI") == "1" return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path + if p == "/api/chat" { + http.Error(w, "chat en clair refusé via le relais : utilise le chat chiffré (boîte noire)", http.StatusForbidden) + return + } if strings.HasPrefix(p, "/v1") || p == "/health" || p == "/props" || p == "/metrics" || strings.HasPrefix(p, "/slots") { + // L'endpoint OpenAI (OpenCode/Hermes) ne peut pas être chiffré navigateur : + // il transiterait en clair par le relais. Désactivé par défaut. + if !oaiAllowed { + http.Error(w, "endpoint OpenAI désactivé via le relais (transiterait en clair) ; JEAN_LINK_ALLOW_OAI=1 pour l'autoriser", http.StatusForbidden) + return + } lp.ServeHTTP(w, r) return } @@ -189,6 +210,9 @@ func runLinkSession(token string, handler http.Handler) error { host, _ := os.Hostname() dialURL := relayURL() q := url.Values{"m": {machineID()}, "h": {host}} + if pk := e2ePubHex(); pk != "" { + q.Set("pk", pk) // clé publique E2E → publiée au relais pour le scellement navigateur + } if strings.Contains(dialURL, "?") { dialURL += "&" + q.Encode() } else { diff --git a/llm.go b/llm.go index 1eb6387..78c5555 100644 --- a/llm.go +++ b/llm.go @@ -3,6 +3,7 @@ package main import ( "bufio" "bytes" + "context" "encoding/json" "fmt" "io" @@ -81,17 +82,37 @@ func runShellTool() Tool { } } -// InjectSkills prepends context system messages to msgs: the machine briefing -// (when machine access is enabled) and the lightweight skills directory (when -// skills are enabled). Merges with an existing system message if present. -func InjectSkills(msgs []Message) []Message { +// Caps are the per-request capabilities (tool access) for a chat turn. They let +// a caller (e.g. an ajean.link agent with its own tools/skills toggles) scope +// what the model can do for this conversation, instead of always inheriting the +// machine's global config. Use globalCaps() to fall back to the global config. +type Caps struct { + Tools bool + Skills bool +} + +// globalCaps reads the machine-wide config — the default when a request doesn't +// specify its own capabilities. +func globalCaps() Caps { + return Caps{Tools: toolsEnabled(), Skills: skillsEnabled()} +} + +// InjectSkills prepends context system messages to msgs: the decisive-agent +// preamble + machine briefing (when tools are enabled) and the lightweight +// skills directory (when skills are enabled). Merges with an existing system +// message if present. +func InjectSkills(msgs []Message, caps Caps) []Message { var parts []string - // Always-on decisive-agent preamble (anti-loop). See baseSystemPrompt. - parts = append(parts, baseSystemPrompt()) - if mp := machineSystemPrompt(); mp != "" { + // Decisive-agent preamble (anti-loop) — only when the model actually has + // tools, otherwise it nudges a plain chat model to "call tools" it doesn't + // have, which leaks malformed tool-call text into the answer. + if bp := baseSystemPrompt(caps); bp != "" { + parts = append(parts, bp) + } + if mp := machineSystemPrompt(caps); mp != "" { parts = append(parts, mp) } - if sp := skillsSystemPrompt(); sp != "" { + if sp := skillsSystemPrompt(caps); sp != "" { parts = append(parts, sp) } if len(parts) == 0 { @@ -107,12 +128,12 @@ func InjectSkills(msgs []Message) []Message { } // EnabledTools returns the tools to advertise on the next inference call. -func EnabledTools() []Tool { +func EnabledTools(caps Caps) []Tool { tools := []Tool{} - if skillsEnabled() { + if caps.Skills { tools = append(tools, skillTool()) } - if toolsEnabled() { + if caps.Tools { tools = append(tools, runShellTool()) } return tools @@ -223,8 +244,8 @@ type streamChunk struct { // and call /v1/chat/completions again — up to 8 iterations as a safety cap. const thinkClose = "" -func runChat(messages []Message, temperature float64, cb ChatCallback) error { - tools := EnabledTools() +func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback) error { + tools := EnabledTools(caps) // Some backends (vanilla llama.cpp builds) don't populate `reasoning_content` // in streaming mode: the model's block (opened by the chat template) // arrives inline in `content`, terminated by a literal . When @@ -257,7 +278,7 @@ func runChat(messages []Message, temperature float64, cb ChatCallback) error { } body, _ := json.Marshal(payload) url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()) - req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) if err != nil { return err } diff --git a/main.go b/main.go index 67b3961..8c67cc3 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,7 @@ import ( "strings" ) -const Version = "0.2.1" +const Version = "0.2.2" func main() { args := os.Args[1:] diff --git a/serve.go b/serve.go index cca917d..bb985e8 100644 --- a/serve.go +++ b/serve.go @@ -61,12 +61,13 @@ func cmdServe(args []string) error { llmArgs = append(llmArgs, "-ctv", vtv) } if r := cfg["REASONING"]; r != "" { - // budget plafonné par défaut (2048) : une réflexion qui part en boucle est - // coupée net au lieu de tourner à l'infini — c'est le comportement de pi. - // NE PAS forcer 0 : sur llama.cpp vanilla, 0 = "immediate end" → coupe tout - // le raisonnement (le fork ik_llama.cpp l'ignore). Mettre -1 pour illimité. - // Configurable via REASONING_BUDGET. - llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "2048")) + // budget illimité par défaut (-1) : on laisse le modèle réfléchir jusqu'au + // bout au lieu de le couper à 2048, ce qui tronquait la vraie réponse (la + // réflexion atteignait le plafond et il ne restait plus de marge pour le + // contenu). L'anti-boucle côté llm.go reste le garde-fou. NE PAS forcer 0 : + // sur llama.cpp vanilla, 0 = "immediate end" → coupe tout le raisonnement + // (le fork ik_llama.cpp l'ignore). Configurable via REASONING_BUDGET. + llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1")) } // API_KEY protège le serveur quand il est exposé sur internet : llama-server // exige alors l'en-tête "Authorization: Bearer ". La clé est lue depuis diff --git a/skills.go b/skills.go index 80b975f..ba0a0dd 100644 --- a/skills.go +++ b/skills.go @@ -156,8 +156,8 @@ func DeleteSkill(name string) error { // skillsSystemPrompt returns the lightweight skills directory message to // prepend to the conversation when skills are enabled. -func skillsSystemPrompt() string { - if !skillsEnabled() { +func skillsSystemPrompt(caps Caps) string { + if !caps.Skills { return "" } list := ListSkills() diff --git a/test_cmd.go b/test_cmd.go index 1097b8e..a523d28 100644 --- a/test_cmd.go +++ b/test_cmd.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "strings" "time" @@ -23,7 +24,7 @@ func cmdTest(args []string) error { t0 := time.Now() var firstTok time.Time tokens := 0 - err := runChat(msgs, 0, func(ev StreamEvent) bool { + err := runChat(context.Background(), msgs, 0, Caps{}, func(ev StreamEvent) bool { if ev.Content != "" { if firstTok.IsZero() { firstTok = time.Now() diff --git a/tools.go b/tools.go index 1178d8e..f25d86a 100644 --- a/tools.go +++ b/tools.go @@ -43,7 +43,13 @@ func setToolsEnabled(on bool) error { // ("Wait, let me check… Wait, I'll just run it…") and commits to an action. // The per-tool "Outil disponible" sections live in machine/skills prompts so // they only appear when the matching feature is on. -func baseSystemPrompt() string { +func baseSystemPrompt(caps Caps) string { + // No tool access → no agentic preamble. A plain chat model told to "call + // tools immediately" hallucinates textual tool calls (e.g. default_api:bash) + // that leak into the answer. Let the user's own system prompt stand alone. + if !caps.Tools && !caps.Skills { + return "" + } var b strings.Builder b.WriteString("Tu es un assistant expert opérant dans jean, un agent qui agit directement sur cette machine. Tu aides l'utilisateur en inspectant le système, en exécutant des commandes et en gérant des skills réutilisables.\n\n") b.WriteString("Méthode :\n") @@ -65,8 +71,8 @@ func baseSystemPrompt() string { // running on, so that when machine access is enabled it knows *which* machine // run_shell acts upon (and doesn't claim it has no access to "your PC"). // Returns "" when machine access is off. -func machineSystemPrompt() string { - if !toolsEnabled() { +func machineSystemPrompt(caps Caps) string { + if !caps.Tools { return "" } host, _ := os.Hostname() diff --git a/ui/index.html b/ui/index.html index e034aab..914859e 100644 --- a/ui/index.html +++ b/ui/index.html @@ -70,6 +70,7 @@ button:disabled{opacity:.5;cursor:not-allowed} #chat{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:14px} .msg{max-width:80%;padding:10px 14px;border-radius:10px;white-space:pre-wrap;word-wrap:break-word;line-height:1.45} .msg.assistant,.msg.reasoning{white-space:normal} +.msg .body{overflow-wrap:anywhere;word-break:break-word} .msg.user{align-self:flex-end;background:#1f6feb;color:#fff} .msg.assistant{align-self:flex-start;background:var(--panel);border:1px solid var(--border)} .msg .body>*{margin:1em 0} @@ -80,13 +81,13 @@ button:disabled{opacity:.5;cursor:not-allowed} .msg .body ul,.msg .body ol{padding-left:1.4em} .msg .body li{margin:.15em 0} .msg .body li>p{margin:0} -.msg.assistant .body code{background:#0d1117;padding:1px 5px;border-radius:3px;font-size:.9em} -.msg.assistant .body pre{position:relative;background:#0d1117;border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;margin:.5em 0} +.msg.assistant .body code{background:#0d1117;padding:1px 5px;border-radius:3px;font-size:.9em;overflow-wrap:anywhere;word-break:break-word} +.msg.assistant .body pre,.msg.reasoning .body pre{position:relative;background:#0d1117;border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;max-width:100%;margin:.5em 0} .copybtn{position:absolute;bottom:6px;right:6px;font-size:11px;padding:3px 8px;margin:0;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:5px;opacity:.5;transition:opacity .15s,color .15s} -.msg.assistant .body pre:hover .copybtn{opacity:1} +.msg.assistant .body pre:hover .copybtn,.msg.reasoning .body pre:hover .copybtn{opacity:1} .copybtn:hover{border-color:var(--accent);color:var(--accent)} .copybtn.done{color:var(--ok);border-color:var(--ok);opacity:1} -.msg.assistant .body pre code{background:transparent;padding:0;font-size:.85em;line-height:1.4} +.msg.assistant .body pre code,.msg.reasoning .body pre code{background:transparent;padding:0;font-size:.85em;line-height:1.4} .msg.assistant .body blockquote{border-left:3px solid var(--border);padding:0 0 0 10px;margin:.4em 0;color:var(--dim)} .msg.assistant .body a{color:var(--accent);text-decoration:none} .msg.assistant .body a:hover{text-decoration:underline} @@ -95,7 +96,7 @@ button:disabled{opacity:.5;cursor:not-allowed} .msg.assistant .body hr{border:none;border-top:1px solid var(--border);margin:.6em 0} #scrollbtn{position:absolute;bottom:80px;right:20px;background:var(--panel);border:1px solid var(--border);color:var(--accent);width:36px;height:36px;border-radius:50%;cursor:pointer;display:none;font-size:16px;z-index:5} #scrollbtn.show{display:block} -.msg.reasoning{align-self:flex-start;background:transparent;border:1px dashed var(--border);color:var(--mag);font-size:12px;max-width:90%} +.msg.reasoning{align-self:flex-start;background:transparent;border:1px dashed var(--border);color:var(--mag);font-size:12px;max-width:90%;overflow-wrap:anywhere;word-break:break-word} .msg.tool{align-self:flex-start;background:var(--panel);border:1px solid var(--border);max-width:100%;white-space:normal;padding:8px 12px} .msg.tool .tool-head{font-size:12px;color:var(--accent);font-weight:600;margin-bottom:6px} .msg.tool .tool-sub{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:8px 0 4px} diff --git a/web.go b/web.go index 31488e9..aa52cb9 100644 --- a/web.go +++ b/web.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "context" "embed" "encoding/json" "fmt" @@ -95,6 +96,7 @@ func newWebMux() *http.ServeMux { api("/api/bench", handleBench) api("/api/bench/last", handleBenchLast) api("/api/chat", handleChat) + api("/api/e2e/chat", handleE2EChat) // chat chiffré E2E (boîte noire via le relais) return mux } @@ -566,18 +568,57 @@ func handleBenchLast(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, map[string]any{"ok": true, "result": sb.Result, "model": sb.Model, "at": sb.At}) } -func handleChat(w http.ResponseWriter, r *http.Request) { - var body struct { - Messages []Message `json:"messages"` - Temperature float64 `json:"temperature"` +// chatReq est le corps d'une requête de chat (commun au chat clair et au chat E2E). +type chatReq struct { + Messages []Message `json:"messages"` + Temperature float64 `json:"temperature"` + // Optional per-request capability overrides (used by ajean.link agents, + // which carry their own tools/skills toggles). nil = inherit the + // machine's global config. + Tools *bool `json:"tools"` + Skills *bool `json:"skills"` +} + +// runChatStream exécute le chat et pousse chaque événement (delta) via emit, qui +// renvoie false pour interrompre. Partagé par handleChat et handleE2EChat. +func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) bool) { + if body.Temperature == 0 { + body.Temperature = 0.7 } + caps := globalCaps() + if body.Tools != nil { + caps.Tools = *body.Tools + } + if body.Skills != nil { + caps.Skills = *body.Skills + } + msgs := InjectSkills(body.Messages, caps) + _ = runChat(ctx, msgs, body.Temperature, caps, func(ev StreamEvent) bool { + if ev.Err != nil { + return emit(map[string]any{"error": ev.Err.Error()}) + } + if ev.ToolUsed != nil { + return emit(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}}) + } + if ev.Stats != nil { + return emit(map[string]any{"stats": ev.Stats}) + } + if ev.Reasoning != "" { + return emit(map[string]any{"reasoning_content": ev.Reasoning}) + } + if ev.Content != "" { + return emit(map[string]any{"content": ev.Content}) + } + return true + }) +} + +func handleChat(w http.ResponseWriter, r *http.Request) { + var body chatReq if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, err.Error(), 400) return } - if body.Temperature == 0 { - body.Temperature = 0.7 - } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("X-Accel-Buffering", "no") @@ -592,27 +633,6 @@ func handleChat(w http.ResponseWriter, r *http.Request) { } return true } - msgs := InjectSkills(body.Messages) - _ = runChat(msgs, body.Temperature, func(ev StreamEvent) bool { - if ev.Err != nil { - emit(map[string]any{"error": ev.Err.Error()}) - return true - } - if ev.ToolUsed != nil { - emit(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}}) - return true - } - if ev.Stats != nil { - emit(map[string]any{"stats": ev.Stats}) - return true - } - if ev.Reasoning != "" { - return emit(map[string]any{"reasoning_content": ev.Reasoning}) - } - if ev.Content != "" { - return emit(map[string]any{"content": ev.Content}) - } - return true - }) + runChatStream(r.Context(), body, emit) }