release: v0.6.0 — security audit fixes + 7 new features
All checks were successful
PR Check / check (pull_request) Successful in 57s
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:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -60,10 +60,17 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var currentMap map[string]interface{}
|
||||
json.Unmarshal(currentJSON, ¤tMap)
|
||||
if err := json.Unmarshal(currentJSON, ¤tMap); 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 != "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user