release: v0.6.0 — security audit fixes + 7 new features
All checks were successful
PR Check / check (pull_request) Successful in 57s

Audit corrections (security, concurrency, stability):
- chat_engine: bound resp.Choices[0] access, release tool slot per-iteration
- conversation_multi: synchronous save under existing lock (was racy fire-and-forget)
- workflow/engine: short-circuit on failed deps (no more infinite busy-wait); track failed/skipped status
- handlers_workflow: rune-aware truncate for plan goal (UTF-8 safe)
- server: CORS limited to localhost origins (was wildcard)
- handlers_info / terminal: mask API keys and SSH passwords as "***" in GET responses; preserve stored secret if "***" sent on update
- terminal: sshpass uses -e + SSHPASS env var (was both -p and -e)
- handlers_chat: MaxBytesReader 50 MB on /api/chat
- image_cache: 10 MB cap per image
- handlers_config: font size <= 72; profile-save unmarshal errors propagated
- handlers_info: /lsp/auto-install ProjectDir restricted to user home
- Shell.jsx: parenthesized resize-condition (operator precedence)
- orchestrator_test: CleanAIResponse capitalization (fixes failing vet)

New features:
- platform: detect OS name (Debian, Ubuntu, Windows 11, macOS X.Y) and inject in Studio system prompt next to the date
- agents: default timeout 30 min for crush_run/claude_run (cap also 30 min)
- agents: new cwd, wsl_distro, wsl_user params; on Windows hosts launch via "wsl -d <distro> -u <user> --cd <cwd> --"
- agents: new claude_run tool (mirror of crush_run for Claude Code CLI)
- terminal: list installed WSL distros individually in new-tab menu (Windows only)
- studio: system prompt rewritten around BMAD-METHOD personas + mandatory delegation template
- studio: "Réflexion avancée" toggle — inactive provider produces a preliminary report injected as [RAPPORT PRÉALABLE] context for the active provider
- studio: "Historique compressé" toggle — collapses past tool calls to last action only, with "Tout afficher" expansion
This commit is contained in:
Muyue
2026-04-27 10:12:11 +02:00
parent 0753167fb9
commit 6a7b4d8001
22 changed files with 804 additions and 145 deletions

View File

@@ -3,6 +3,7 @@ package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/muyue/muyue/internal/agent"
@@ -85,6 +86,9 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
ce.TotalTokens += resp.Usage.TotalTokens
}
if len(resp.Choices) == 0 {
return finalContent, allToolCalls, allToolResults, fmt.Errorf("empty response from provider")
}
choice := resp.Choices[0]
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
@@ -124,8 +128,9 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
Arguments: json.RawMessage(tc.Function.Arguments),
}
var release func()
if ce.limiter != nil {
release, limitErr := ce.limiter(tc.Function.Name)
rel, limitErr := ce.limiter(tc.Function.Name)
if limitErr != nil {
limResultData := map[string]interface{}{
"tool_call_id": tc.ID,
@@ -150,10 +155,13 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
})
continue
}
defer release()
release = rel
}
result, execErr := ce.registry.Execute(ctx, call)
if release != nil {
release()
}
if execErr != nil {
result = agent.ToolResponse{
Content: execErr.Error(),
@@ -216,6 +224,9 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
ce.TotalTokens += resp.Usage.TotalTokens
}
if len(resp.Choices) == 0 {
return finalContent, fmt.Errorf("empty response from provider")
}
choice := resp.Choices[0]
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
@@ -241,8 +252,9 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
Arguments: json.RawMessage(tc.Function.Arguments),
}
var release func()
if ce.limiter != nil {
release, limitErr := ce.limiter(tc.Function.Name)
rel, limitErr := ce.limiter(tc.Function.Name)
if limitErr != nil {
messages = append(messages, orchestrator.Message{
Role: "tool",
@@ -252,10 +264,13 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
})
continue
}
defer release()
release = rel
}
result, execErr := ce.registry.Execute(ctx, call)
if release != nil {
release()
}
if execErr != nil {
result = agent.ToolResponse{
Content: execErr.Error(),
@@ -310,6 +325,5 @@ func SetupSSEHeaders(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK)
}

View File

@@ -222,9 +222,9 @@ func (cs *ConversationStoreMulti) Add(role, content string) FeedMessage {
Time: time.Now().Format(time.RFC3339),
}
conv.Messages = append(conv.Messages, msg)
go cs.saveCurrent() // Fire and forget
cs.saveCurrent()
return msg
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
"github.com/muyue/muyue/internal/platform"
)
var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
@@ -131,10 +132,12 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 50*1024*1024)
var body struct {
Message string `json:"message"`
Stream bool `json:"stream"`
Images []ImageAttachment `json:"images"`
Message string `json:"message"`
Stream bool `json:"stream"`
Images []ImageAttachment `json:"images"`
AdvancedReflection bool `json:"advanced_reflection"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
@@ -194,7 +197,12 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
}
var studioPrompt strings.Builder
studioPrompt.WriteString(agent.StudioSystemPrompt())
studioPrompt.WriteString(fmt.Sprintf("\nDate: %s\nHeure: %s\n", time.Now().Format("02/01/2006"), time.Now().Format("15:04:05")))
sysInfo := platform.Detect()
osName := sysInfo.OSName
if osName == "" {
osName = string(sysInfo.OS)
}
studioPrompt.WriteString(fmt.Sprintf("\nDate: %s\nHeure: %s\nSystème: %s\n", time.Now().Format("02/01/2006"), time.Now().Format("15:04:05"), osName))
canSudo := !agent.NeedsSudoPassword()
studioPrompt.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
if !canSudo {
@@ -205,6 +213,12 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
orb.SetSystemPrompt(studioPrompt.String())
orb.SetTools(s.agentToolsJSON)
if body.AdvancedReflection {
if report, ok := s.runReflectionReport(enrichedMessage); ok {
enrichedMessage = enrichedMessage + "\n\n[RAPPORT PRÉALABLE — produit par un autre modèle, à valider]\n" + report + "\n[/RAPPORT PRÉALABLE]"
}
}
if body.Stream {
s.handleStreamChat(w, orb, enrichedMessage)
} else {
@@ -281,6 +295,23 @@ func cleanThinkingTags(content string) string {
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
}
// runReflectionReport runs the inactive AI provider on the user message to
// produce a preliminary analysis report that the active provider will then
// use as additional context. Returns ("", false) if no inactive provider is
// configured or on error — the caller falls back to a normal chat flow.
func (s *Server) runReflectionReport(userMessage string) (string, bool) {
orb, err := orchestrator.NewForInactiveProvider(s.config)
if err != nil {
return "", false
}
orb.SetSystemPrompt("Tu es un analyste. Pour la question ci-dessous, produis un rapport bref (max 15 lignes) qui : (1) reformule l'objectif de l'utilisateur, (2) liste les points à clarifier ou les risques, (3) suggère une approche structurée. Pas de code, pas d'action — uniquement de l'analyse.")
resp, err := orb.SendNoTools(userMessage)
if err != nil {
return "", false
}
return strings.TrimSpace(resp), true
}
func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message {
history := s.convStore.Get()

View File

@@ -60,10 +60,17 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
return
}
var currentMap map[string]interface{}
json.Unmarshal(currentJSON, &currentMap)
if err := json.Unmarshal(currentJSON, &currentMap); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
var updates map[string]interface{}
body, _ := io.ReadAll(r.Body)
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if err := json.Unmarshal(body, &updates); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
@@ -71,8 +78,15 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
deepMerge(currentMap, updates)
mergedJSON, _ := json.Marshal(currentMap)
json.Unmarshal(mergedJSON, &s.config.Profile)
mergedJSON, err := json.Marshal(currentMap)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.Unmarshal(mergedJSON, &s.config.Profile); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
@@ -122,7 +136,7 @@ func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
found := false
for i := range s.config.AI.Providers {
if s.config.AI.Providers[i].Name == body.Name {
if body.APIKey != "" {
if body.APIKey != "" && body.APIKey != "***" {
s.config.AI.Providers[i].APIKey = body.APIKey
}
if body.Model != "" {
@@ -173,6 +187,14 @@ func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request)
writeError(w, "api_key required", http.StatusBadRequest)
return
}
if body.APIKey == "***" {
for _, p := range s.config.AI.Providers {
if p.Name == body.Name {
body.APIKey = p.APIKey
break
}
}
}
baseURL := body.BaseURL
if baseURL == "" {
@@ -266,7 +288,7 @@ func (s *Server) handleSaveTerminalSettings(w http.ResponseWriter, r *http.Reque
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.FontSize > 0 {
if body.FontSize > 0 && body.FontSize <= 72 {
s.config.Terminal.FontSize = body.FontSize
}
if body.FontFamily != "" {

View File

@@ -80,8 +80,23 @@ func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
writeError(w, "no config", http.StatusNotFound)
return
}
masked := make([]map[string]interface{}, 0, len(s.config.AI.Providers))
for _, p := range s.config.AI.Providers {
entry := map[string]interface{}{
"name": p.Name,
"model": p.Model,
"base_url": p.BaseURL,
"active": p.Active,
}
if p.APIKey != "" {
entry["api_key"] = "***"
} else {
entry["api_key"] = ""
}
masked = append(masked, entry)
}
writeJSON(w, map[string]interface{}{
"providers": s.config.AI.Providers,
"providers": masked,
})
}
@@ -203,9 +218,20 @@ func (s *Server) handleLSPAutoInstall(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&body)
}
home, _ := os.UserHomeDir()
if body.ProjectDir == "" {
home, _ := os.UserHomeDir()
body.ProjectDir = home
} else {
abs, err := filepath.Abs(body.ProjectDir)
if err != nil {
writeError(w, "invalid project_dir", http.StatusBadRequest)
return
}
body.ProjectDir = abs
if home != "" && !strings.HasPrefix(abs, home+string(filepath.Separator)) && abs != home {
writeError(w, "project_dir must be within user home", http.StatusBadRequest)
return
}
}
results, err := lsp.AutoInstallForProject(body.ProjectDir)

View File

@@ -144,7 +144,7 @@ func (s *Server) handleWorkflowPlan(w http.ResponseWriter, r *http.Request) {
engine, _ = workflow.NewEngine(s.agentRegistry)
}
wf := engine.Create("Plan: "+body.Goal[:min(len(body.Goal), 30)], body.Goal, "plan_execute", steps)
wf := engine.Create("Plan: "+truncateString(body.Goal, 30), body.Goal, "plan_execute", steps)
writeJSON(w, wf)
}
@@ -188,7 +188,6 @@ func (s *Server) handleWorkflowExecuteStream(w http.ResponseWriter, engine *work
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK)
flusher, canFlush := w.(http.Flusher)
@@ -250,9 +249,10 @@ func (s *Server) handleWorkflowApprove(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "approved"})
}
func min(a, b int) int {
if a < b {
return a
func truncateString(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return b
return string(runes[:max])
}

View File

@@ -37,6 +37,9 @@ func saveImage(dataURI, filename, mimeType string) (string, error) {
if err != nil {
return "", fmt.Errorf("base64 decode: %w", err)
}
if len(decoded) > 10*1024*1024 {
return "", fmt.Errorf("image too large (max 10MB)")
}
id := fmt.Sprintf("%d-%d", time.Now().UnixMilli(), atomic.AddUint64(&imageCounter, 1))
ext := ".png"

View File

@@ -154,8 +154,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
if origin := r.Header.Get("Origin"); isAllowedOrigin(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
@@ -164,6 +167,22 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}
func isAllowedOrigin(origin string) bool {
if origin == "" {
return false
}
switch {
case strings.HasPrefix(origin, "http://127.0.0.1"),
strings.HasPrefix(origin, "http://localhost"),
strings.HasPrefix(origin, "http://[::1]"),
strings.HasPrefix(origin, "https://127.0.0.1"),
strings.HasPrefix(origin, "https://localhost"),
strings.HasPrefix(origin, "https://[::1]"):
return true
}
return false
}
const maxCrushAgents = 2
const maxClaudeAgents = 2

View File

@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
@@ -96,7 +97,10 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
if sshConf.Password != "" {
sshpassPath, err := exec.LookPath("sshpass")
if err == nil {
cmd = exec.Command(sshpassPath, append([]string{"-p", sshConf.Password}, append([]string{"-e"}, sshArgs...)...)...)
args := append([]string{"-e"}, "ssh")
args = append(args, sshArgs...)
cmd = exec.Command(sshpassPath, args...)
cmd.Env = append(os.Environ(), "SSHPASS="+sshConf.Password)
} else {
cmd = exec.Command("ssh", sshArgs...)
}
@@ -113,29 +117,42 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
shell = "/bin/sh"
}
if path, err := exec.LookPath(shell); err == nil {
shell = path
}
// Support "wsl -d <distro>" shell strings sent from the UI quick-access.
if extra, ok := parseWSLShell(shell); ok {
wslPath, err := exec.LookPath("wsl")
if err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "wsl not found on this host"})
return
}
cmd = exec.Command(wslPath, extra...)
} else {
if path, err := exec.LookPath(shell); err == nil {
shell = path
}
if _, err := os.Stat(shell); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
return
}
if _, err := os.Stat(shell); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
return
}
shellName := filepath.Base(shell)
switch shellName {
case "wsl":
cmd = exec.Command(shell, "--shell-type", "login")
case "powershell", "pwsh":
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
case "fish":
cmd = exec.Command(shell, "--login")
default:
cmd = exec.Command(shell)
shellName := filepath.Base(shell)
switch shellName {
case "wsl":
cmd = exec.Command(shell, "--shell-type", "login")
case "powershell", "pwsh":
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
case "fish":
cmd = exec.Command(shell, "--login")
default:
cmd = exec.Command(shell)
}
}
}
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
if cmd.Env == nil {
cmd.Env = os.Environ()
}
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
ptmx, err := pty.Start(cmd)
if err != nil {
@@ -207,8 +224,15 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
masked := make([]config.SSHConnection, len(s.config.Terminal.SSH))
for i, c := range s.config.Terminal.SSH {
masked[i] = c
if masked[i].Password != "" {
masked[i].Password = "***"
}
}
writeJSON(w, map[string]interface{}{
"ssh": s.config.Terminal.SSH,
"ssh": masked,
"system": detectSystemTerminals(),
})
return
@@ -239,13 +263,17 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
for i, c := range s.config.Terminal.SSH {
if c.Name == body.Name {
password := body.Password
if password == "***" {
password = c.Password
}
s.config.Terminal.SSH[i] = config.SSHConnection{
Name: body.Name,
Host: body.Host,
Port: body.Port,
User: body.User,
KeyPath: body.KeyPath,
Password: body.Password,
Password: password,
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
@@ -314,6 +342,87 @@ func detectShell() string {
return "/bin/sh"
}
// listWSLDistros returns the list of installed WSL distribution names.
// Windows hosts only — returns nil on other platforms or if WSL is unavailable.
func listWSLDistros() []string {
if runtime.GOOS != "windows" {
return nil
}
out, err := exec.Command("wsl", "--list", "--quiet").Output()
if err != nil {
return nil
}
// `wsl --list --quiet` outputs UTF-16LE on Windows. Strip BOM and decode best-effort.
raw := stripUTF16ToASCII(out)
var distros []string
seen := make(map[string]bool)
for _, line := range strings.Split(raw, "\n") {
name := strings.TrimSpace(line)
if name == "" || seen[name] {
continue
}
// Skip default-marker arrows or annotations.
name = strings.TrimSpace(strings.TrimPrefix(name, "*"))
if name == "" || !validWSLName.MatchString(name) {
continue
}
seen[name] = true
distros = append(distros, name)
}
return distros
}
var validWSLName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
// parseWSLShell recognises strings of the form "wsl -d <distro>" (and optionally
// "-u <user>") emitted by the Shell tab quick-access menu, returning the args
// to pass to the wsl binary. Returns ok=false otherwise.
func parseWSLShell(shell string) ([]string, bool) {
parts := strings.Fields(shell)
if len(parts) < 3 || parts[0] != "wsl" {
return nil, false
}
args := []string{}
i := 1
for i < len(parts) {
switch parts[i] {
case "-d", "--distribution":
if i+1 >= len(parts) || !validWSLName.MatchString(parts[i+1]) {
return nil, false
}
args = append(args, "-d", parts[i+1])
i += 2
case "-u", "--user":
if i+1 >= len(parts) || !validWSLName.MatchString(parts[i+1]) {
return nil, false
}
args = append(args, "-u", parts[i+1])
i += 2
default:
return nil, false
}
}
if len(args) == 0 {
return nil, false
}
return args, true
}
func stripUTF16ToASCII(b []byte) string {
// Best-effort: keep only printable bytes (drop high bytes from UTF-16LE pairs).
var out []byte
for i := 0; i < len(b); i++ {
c := b[i]
if c == 0 {
continue
}
if c >= 32 && c < 127 || c == '\n' || c == '\r' || c == '\t' {
out = append(out, c)
}
}
return string(out)
}
func detectSystemTerminals() []map[string]string {
var terminals []map[string]string
@@ -326,10 +435,17 @@ func detectSystemTerminals() []map[string]string {
if runtime.GOOS == "windows" {
if _, err := exec.LookPath("wsl"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "WSL",
"type": "local",
"name": "WSL (default)",
"shell": "wsl",
})
for _, distro := range listWSLDistros() {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "WSL: " + distro,
"shell": "wsl -d " + distro,
})
}
}
if _, err := exec.LookPath("powershell"); err == nil {
terminals = append(terminals, map[string]string{