feat: AI task API, token-based context windows, SSH password auth, sudo bypass detection
All checks were successful
Beta Release / beta (push) Successful in 1m6s

Replace message-count context windows with token-budget based ones for both
studio and shell. Add /api/ai/task endpoint for background tool
check/install/update. Enhance sudo blocking to catch piped/chained elevation
commands. Add SSH password support via sshpass and connection editing UI.
Remove realTokens persistence in favor of consumption tracking. Bump to 0.4.1.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-26 20:06:20 +02:00
parent 12000e523c
commit e8a289ccf3
16 changed files with 446 additions and 105 deletions

View File

@@ -56,9 +56,30 @@ func NewTerminalTool() (*ToolDefinition, error) {
if NeedsSudoPassword() {
trimmed := strings.TrimSpace(p.Command)
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ") {
prefixBlocked := strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ")
anywhereBlocked := false
blockedCmd := ""
if !prefixBlocked {
for _, kw := range []string{"sudo", "doas", "run0", "pkexec"} {
for _, pattern := range []string{" " + kw + " ", "|" + kw + " ", ";" + kw + " ", "&&" + kw + " ", "||" + kw + " ", "`" + kw + " ", "$(" + kw + " "} {
if strings.Contains(lower, pattern) {
anywhereBlocked = true
blockedCmd = kw
break
}
}
if anywhereBlocked {
break
}
}
}
if prefixBlocked || anywhereBlocked {
elevCmd := blockedCmd
if prefixBlocked {
elevCmd = strings.Fields(trimmed)[0]
}
return ToolResponse{
Content: fmt.Sprintf("BLOCKED: Command '%s' requires elevated privileges (%s). The current user is not root. Do NOT retry with sudo. Explain to the user that this command needs admin privileges and suggest an alternative, or tell them to run it manually in their terminal.", trimmed, strings.Fields(trimmed)[0]),
Content: fmt.Sprintf("BLOCKED: Command '%s' requires elevated privileges (%s). Passwordless sudo is not available. Do NOT retry with sudo. Explain to the user that this command needs admin privileges and suggest an alternative, or tell them to run it manually in their terminal.", trimmed, elevCmd),
IsError: true,
Meta: map[string]string{"sudo_blocked": "true", "command": trimmed},
}, nil

View File

@@ -28,7 +28,6 @@ type FeedMessage struct {
type Conversation struct {
Messages []FeedMessage `json:"messages"`
Summary string `json:"summary,omitempty"`
RealTokens int `json:"real_tokens,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
@@ -37,7 +36,6 @@ type ConversationStore struct {
mu sync.RWMutex
path string
conv *Conversation
realTokens int
}
type TokenCount struct {
@@ -87,7 +85,6 @@ func (cs *ConversationStore) load() {
conv.Messages = []FeedMessage{}
}
cs.conv = &conv
cs.realTokens = conv.RealTokens
}
func (cs *ConversationStore) save() error {
@@ -157,10 +154,8 @@ func (cs *ConversationStore) Clear() {
cs.conv.Messages = []FeedMessage{}
cs.conv.Summary = ""
cs.conv.RealTokens = 0
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
cs.realTokens = 0
cs.save()
go cleanupImages(imageIDs)
@@ -184,23 +179,9 @@ func (cs *ConversationStore) TrimOld(keepCount int) {
}
func (cs *ConversationStore) ApproxTokenCount() int {
if cs.realTokens > 0 {
return cs.realTokens
}
return cs.ApproxTokenCountDetailed().total
}
// AddRealTokens accumulates actual token counts from the API response.
func (cs *ConversationStore) AddRealTokens(tokens int) {
if tokens <= 0 {
return
}
cs.mu.Lock()
cs.realTokens += tokens
cs.conv.RealTokens = cs.realTokens
cs.mu.Unlock()
}
func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
cs.mu.RLock()
defer cs.mu.RUnlock()

View File

@@ -0,0 +1,172 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"runtime"
"strings"
"time"
"github.com/muyue/muyue/internal/orchestrator"
)
func (s *Server) handleAITask(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Task string `json:"task"`
Tool string `json:"tool,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Task == "" {
writeError(w, "task is required", http.StatusBadRequest)
return
}
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, "AI not available: "+err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(buildAITaskSystemPrompt())
orb.SetTools(s.shellAgentToolsJSON)
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
messages := []orchestrator.Message{
{Role: "user", Content: orchestrator.TextContent(buildAITaskPrompt(body.Task, body.Tool))},
}
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, "AI task failed: "+err.Error(), http.StatusInternalServerError)
return
}
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
parsed := parseAIJSONResponse(finalContent)
writeJSON(w, map[string]interface{}{
"status": "ok",
"raw": finalContent,
"result": parsed,
"tokens": engine.TotalTokens,
})
}
func buildAITaskSystemPrompt() string {
return fmt.Sprintf(`You are a system administration assistant. You have access to a terminal tool to run commands on the host system.
IMPORTANT RULES:
- You MUST respond ONLY with valid JSON. No markdown, no code fences, no extra text.
- Always run the actual commands needed to complete the task.
- Be thorough: check versions, verify installations, compare with latest releases.
OS: %s/%s
Date: %s
`, runtime.GOOS, runtime.GOARCH, time.Now().Format("2006-01-02"))
}
func buildAITaskPrompt(task, tool string) string {
switch task {
case "check_tools":
return `Check the following tools on this system. For each tool, determine:
1. Is it installed? Run "which <tool>" or "<tool> --version"
2. If installed, what is the current version?
3. What is the latest available version? Check GitHub releases API or official sources.
Tools to check: crush, claude, git, node, npm, pnpm, python3, pip3, uv, go, docker, gh, starship, npx
Run the commands needed, then respond with ONLY this JSON structure (no markdown fences):
{
"tools": [
{"name": "tool_name", "installed": true/false, "version": "x.y.z", "latest": "a.b.c", "needs_update": true/false, "category": "ai|runtime|vcs|devops|prompt"}
]
}`
case "install_tool":
return fmt.Sprintf(`Install the tool "%s" on this system.
Steps:
1. Check if it's already installed: run "which %s" and "%s --version"
2. If not installed, determine the best installation method for this OS
3. Run the installation command
4. Verify the installation succeeded
Respond with ONLY this JSON (no markdown fences):
{
"tool": "%s",
"installed": true/false,
"version": "installed version or empty",
"message": "what was done",
"error": "error message or empty"
}`, tool, tool, tool, tool)
case "update_tool":
return fmt.Sprintf(`Update the tool "%s" to its latest version on this system.
Steps:
1. Check current version: run "%s --version"
2. Find the latest version available
3. Run the update/upgrade command
4. Verify the new version
Respond with ONLY this JSON (no markdown fences):
{
"tool": "%s",
"previous_version": "old version",
"version": "new version",
"updated": true/false,
"message": "what was done",
"error": "error message or empty"
}`, tool, tool, tool)
default:
return task
}
}
func parseAIJSONResponse(content string) interface{} {
cleaned := content
if idx := strings.Index(cleaned, "```json"); idx != -1 {
cleaned = cleaned[idx+7:]
if end := strings.Index(cleaned, "```"); end != -1 {
cleaned = cleaned[:end]
}
} else if idx := strings.Index(cleaned, "```"); idx != -1 {
cleaned = cleaned[idx+3:]
if end := strings.Index(cleaned, "```"); end != -1 {
cleaned = cleaned[:end]
}
}
cleaned = strings.TrimSpace(cleaned)
jsonStart := strings.Index(cleaned, "{")
jsonEnd := strings.LastIndex(cleaned, "}")
if jsonStart != -1 && jsonEnd > jsonStart {
cleaned = cleaned[jsonStart : jsonEnd+1]
}
var result interface{}
if err := json.Unmarshal([]byte(cleaned), &result); err != nil {
return map[string]interface{}{
"raw": content,
"error": "failed to parse AI response as JSON",
}
}
return result
}

View File

@@ -13,6 +13,7 @@ import (
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
@@ -253,7 +254,6 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
storeContent = string(storeJSON)
}
s.convStore.Add("assistant", storeContent)
s.convStore.AddRealTokens(engine.TotalTokens)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
@@ -272,7 +272,6 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
}
s.convStore.Add("assistant", finalContent)
s.convStore.AddRealTokens(engine.TotalTokens)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
@@ -283,19 +282,47 @@ func cleanThinkingTags(content string) string {
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
}
const contextWindowMessages = 20
func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message {
history := s.convStore.Get()
start := 0
if len(history) > contextWindowMessages {
start = len(history) - contextWindowMessages
sysPromptTokens := utf8.RuneCountInString(agent.StudioSystemPrompt())/charsPerToken + 50
toolsTokens := utf8.RuneCountInString(string(s.agentToolsJSON)) / charsPerToken
responseMargin := 4000
userMsgTokens := utf8.RuneCountInString(userMessage) / charsPerToken
overhead := sysPromptTokens + toolsTokens + responseMargin + userMsgTokens
available := contextWindowTokens - overhead
if available < 1000 {
available = 1000
}
messages := make([]orchestrator.Message, 0, len(history[start:])+1)
included := 0
tokensUsed := 0
for i := len(history) - 1; i >= 0; i-- {
msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken
if msgTokens == 0 {
msgTokens = 1
}
if tokensUsed+msgTokens > available {
break
}
tokensUsed += msgTokens
included++
}
start := len(history) - included
if start < 0 {
start = 0
}
if start > 0 {
log.Printf("[studio] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, contextWindowTokens, included, len(history), start)
}
messages := make([]orchestrator.Message, 0, included+2)
summary := s.convStore.GetSummary()
if summary != "" {
if summary != "" && start > 0 {
messages = append(messages, orchestrator.Message{
Role: "system",
Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary),

View File

@@ -4,12 +4,14 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
@@ -133,7 +135,6 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.
storeContent = string(storeJSON)
}
s.shellConvStore.Add("assistant", storeContent)
s.shellConvStore.AddRealTokens(engine.TotalTokens)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
@@ -155,7 +156,6 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat
}
s.shellConvStore.Add("assistant", finalContent)
s.shellConvStore.AddRealTokens(engine.TotalTokens)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
@@ -167,13 +167,45 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat
func (s *Server) buildShellContextMessages() []orchestrator.Message {
history := s.shellConvStore.Get()
start := 0
const shellContextWindow = 20
if len(history) > shellContextWindow {
start = len(history) - shellContextWindow
sysTokens := utf8.RuneCountInString(shellSystemPromptBase) / charsPerToken
if analysis := LoadSystemAnalysis(); analysis != "" {
sysTokens += utf8.RuneCountInString(analysis) / charsPerToken
}
sysTokens += 100
toolsTokens := utf8.RuneCountInString(string(s.shellAgentToolsJSON)) / charsPerToken
responseMargin := 4000
overhead := sysTokens + toolsTokens + responseMargin
available := shellMaxTokens - overhead
if available < 1000 {
available = 1000
}
messages := make([]orchestrator.Message, 0, len(history[start:]))
included := 0
tokensUsed := 0
for i := len(history) - 1; i >= 0; i-- {
msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken
if msgTokens == 0 {
msgTokens = 1
}
if tokensUsed+msgTokens > available {
break
}
tokensUsed += msgTokens
included++
}
start := len(history) - included
if start < 0 {
start = 0
}
if start > 0 {
log.Printf("[shell] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, shellMaxTokens, included, len(history), start)
}
messages := make([]orchestrator.Message, 0, included)
for _, m := range history[start:] {
content := m.Content

View File

@@ -133,6 +133,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
s.mux.HandleFunc("/api/ai/task", s.handleAITask)
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
s.mux.HandleFunc("/api/providers/consumption", s.handleProvidersConsumption)
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)

View File

@@ -82,7 +82,6 @@ type ShellConvStore struct {
mu sync.RWMutex
path string
msgs []ShellMessage
realTokens int
}
func NewShellConvStore() *ShellConvStore {
@@ -140,14 +139,10 @@ func (s *ShellConvStore) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.msgs = []ShellMessage{}
s.realTokens = 0
s.save()
}
func (s *ShellConvStore) ApproxTokens() int {
if s.realTokens > 0 {
return s.realTokens
}
s.mu.RLock()
defer s.mu.RUnlock()
total := 0
@@ -161,16 +156,6 @@ func (s *ShellConvStore) ApproxTokens() int {
return total
}
// AddRealTokens accumulates actual token counts from the API response.
func (s *ShellConvStore) AddRealTokens(tokens int) {
if tokens <= 0 {
return
}
s.mu.Lock()
s.realTokens += tokens
s.mu.Unlock()
}
func (s *ShellConvStore) AtLimit() bool {
return s.ApproxTokens() >= shellMaxTokens
}

View File

@@ -76,6 +76,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
Port int `json:"port"`
User string `json:"user"`
KeyPath string `json:"key_path"`
Password string `json:"password"`
}
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
@@ -98,7 +99,16 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
}
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
if sshConf.Password != "" {
sshpassPath, err := exec.LookPath("sshpass")
if err == nil {
cmd = exec.Command(sshpassPath, append([]string{"-p", sshConf.Password}, append([]string{"-e"}, sshArgs...)...)...)
} else {
cmd = exec.Command("ssh", sshArgs...)
}
} else {
cmd = exec.Command("ssh", sshArgs...)
}
} else {
shell := strings.TrimSpace(initMsg.Data)
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
@@ -227,6 +237,7 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
Port int `json:"port"`
User string `json:"user"`
KeyPath string `json:"key_path"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
@@ -240,12 +251,32 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
body.Port = 22
}
for i, c := range s.config.Terminal.SSH {
if c.Name == body.Name {
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,
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
return
}
}
conn := config.SSHConnection{
Name: body.Name,
Host: body.Host,
Port: body.Port,
User: body.User,
KeyPath: body.KeyPath,
Password: body.Password,
}
if s.config.Terminal.SSH == nil {
s.config.Terminal.SSH = []config.SSHConnection{}

View File

@@ -7,7 +7,7 @@ import (
const (
Name = "muyue"
Version = "0.4.0"
Version = "0.4.1"
Author = "La Légion de Muyue"
)

View File

@@ -49,6 +49,7 @@ const api = {
applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }),
validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }),
runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
aiTask: (task, tool) => request('/ai/task', { method: 'POST', body: JSON.stringify({ task, tool: tool || '' }) }),
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
getTerminalSessions: () => request('/terminal/sessions'),
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),

View File

@@ -146,7 +146,7 @@ export default function App() {
<main className="content">
<div className={activeTab === 'dash' ? '' : 'tab-hidden'}><Dashboard api={api} refreshRef={dashRefreshRef} /></div>
<div className={activeTab === 'studio' ? '' : 'tab-hidden'}><Studio api={api} /></div>
<div className={activeTab === 'shell' ? '' : 'tab-hidden'}><Shell api={api} /></div>
<div className={activeTab === 'shell' ? '' : 'tab-hidden'}><Shell api={api} isSudo={isSudo} /></div>
<div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div>
</main>

View File

@@ -49,27 +49,79 @@ export default function Config({ api }) {
const handleCheckUpdates = async () => {
setChecking(true)
try {
await api.runScan()
const d = await api.getUpdates()
setUpdates(d.updates || [])
const td = await api.getTools()
setTools(td.tools || [])
const d = await api.aiTask('check_tools')
const result = d.result
if (result && result.tools) {
const aiTools = result.tools
const newUpdates = aiTools.filter(t => t.installed).map(t => ({
tool: t.name,
current: t.version || '',
latest: t.latest || '',
needsUpdate: t.needs_update || false,
error: t.error || '',
}))
const newTools = aiTools.map(t => ({
name: t.name,
installed: t.installed,
version: t.version || '',
category: t.category || '',
}))
setUpdates(newUpdates)
setTools(newTools)
showToast(t('config.upToDate'))
} else {
showToast(t('config.error'))
}
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setChecking(false)
}
const handleUpdateTool = (tool) => {
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour l'outil ${tool} sur mon système. Exécute les commandes nécessaires.` } }))
const handleUpdateTool = async (tool) => {
setUpdating(tool)
try {
const d = await api.aiTask('update_tool', tool)
if (d.result && d.result.updated) {
showToast(`${tool} ${t('config.updated') || 'mis à jour'}`)
} else {
showToast(d.result?.error || d.result?.message || t('config.error'))
}
handleCheckUpdates()
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
}
const handleUpdateAll = () => {
const toUpdate = updates.filter(u => u.needsUpdate).map(u => u.tool)
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour tous les outils suivants sur mon système : ${toUpdate.join(', ')}. Exécute les commandes nécessaires une par une.` } }))
const handleInstallTool = async (tool) => {
setUpdating(`install-${tool}`)
try {
const d = await api.aiTask('install_tool', tool)
if (d.result && d.result.installed) {
showToast(`${tool} ${t('config.installed') || 'installé'}`)
} else {
showToast(d.result?.error || d.result?.message || t('config.error'))
}
handleCheckUpdates()
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
}
const handleUpdateAll = async () => {
const toUpdate = updates.filter(u => u.needsUpdate)
setUpdating('__all__')
for (const u of toUpdate) {
try {
await api.aiTask('update_tool', u.tool)
} catch (err) {
console.error(`Failed to update ${u.tool}:`, err)
}
}
setUpdating(null)
handleCheckUpdates()
}
const handleSaveProfile = async () => {
@@ -160,6 +212,7 @@ export default function Config({ api }) {
installedCount={installedCount} missingCount={missingCount}
handleCheckUpdates={handleCheckUpdates}
handleUpdateTool={handleUpdateTool}
handleInstallTool={handleInstallTool}
handleUpdateAll={handleUpdateAll}
t={t}
/>
@@ -406,11 +459,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
)
}
function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
const handleInstallTool = (tool) => {
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Installe l'outil ${tool} sur mon système. Vérifie d'abord s'il est déjà installé, puis installe-le si nécessaire avec les commandes appropriées.` } }))
}
function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleInstallTool, handleUpdateAll, t }) {
const missingTools = tools.filter(tool => !tool.installed)

View File

@@ -225,6 +225,7 @@ function createTerminal(container, settings = {}) {
theme,
allowTransparency: false,
scrollback: 5000,
bracketedPaste: false,
})
const fitAddon = new FitAddon()
@@ -362,7 +363,7 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMes
return ws
}
export default function Shell({ api }) {
export default function Shell({ api, isSudo }) {
const { t } = useI18n()
const tabsRef = useRef({})
const nextIdRef = useRef(1)
@@ -456,8 +457,9 @@ export default function Shell({ api }) {
}, [])
const [sshForm, setSshForm] = useState({
name: '', host: '', port: 22, user: '', key_path: '',
name: '', host: '', port: 22, user: '', key_path: '', password: '',
})
const [sshEditing, setSshEditing] = useState(null)
const [aiMessages, setAiMessages] = useState([])
const [aiInput, setAiInput] = useState('')
@@ -552,6 +554,7 @@ export default function Shell({ api }) {
port: tab.port || 22,
user: tab.user || 'root',
key_path: tab.key_path || '',
password: tab.password || '',
}),
}
} else {
@@ -893,6 +896,7 @@ export default function Shell({ api }) {
port: conn.port || 22,
user: conn.user || 'root',
key_path: conn.key_path || '',
password: conn.password || '',
connected: false,
}
setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
@@ -963,14 +967,26 @@ export default function Shell({ api }) {
if (!sshForm.name.trim() || !sshForm.host.trim()) return
try {
await api.addSSHConnection(sshForm)
if (sshEditing) {
setSshConnections(prev => prev.map(c => c.name === sshEditing ? { ...sshForm } : c))
} else {
setSshConnections(prev => [...prev, { ...sshForm }])
setSshForm({ name: '', host: '', port: 22, user: '', key_path: '' })
}
setSshForm({ name: '', host: '', port: 22, user: '', key_path: '', password: '' })
setSshEditing(null)
setShowSshModal(false)
} catch (err) {
console.error(err)
}
}
const editSSHConnection = (conn) => {
setSshForm({ name: conn.name, host: conn.host, port: conn.port || 22, user: conn.user || '', key_path: conn.key_path || '', password: conn.password || '' })
setSshEditing(conn.name)
setShowSshModal(true)
setShowMenu(false)
}
const deleteSSHConnection = async (name) => {
try {
await api.deleteSSHConnection(name)
@@ -1300,6 +1316,13 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
<span>{conn.name}</span>
<span className="shell-menu-item-sub">{conn.user}@{conn.host}:{conn.port}</span>
</button>
<button
className="shell-menu-item-icon"
onClick={(e) => { e.stopPropagation(); editSSHConnection(conn) }}
title={t('shell.editConnection')}
>
<Pencil size={11} />
</button>
<button
className="shell-menu-item-icon"
onClick={(e) => { e.stopPropagation(); deleteSSHConnection(conn.name) }}
@@ -1355,7 +1378,10 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
<div className="shell-ai-col">
<div className="ai-panel-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>Analyste Système</span>
<span className={`sudo-indicator ${isSudo ? 'sudo-ok' : 'sudo-blocked'}`} title={isSudo ? 'Sudo sans mot de passe disponible' : 'Sudo bloqué — mot de passe requis'} />
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button
className="shell-analyze-btn"
@@ -1468,15 +1494,16 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
)}
{showSshModal && (
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
<div className="shell-modal-overlay" onClick={() => { setShowSshModal(false); setSshEditing(null) }}>
<div className="shell-modal" onClick={e => e.stopPropagation()}>
<div className="shell-modal-header">{t('shell.addConnection')}</div>
<div className="shell-modal-header">{sshEditing ? t('shell.editConnection') : t('shell.addConnection')}</div>
<div className="shell-modal-body">
<label className="shell-modal-label">{t('shell.connectionName')}</label>
<input
value={sshForm.name}
onChange={e => setSshForm(f => ({ ...f, name: e.target.value }))}
placeholder="prod-server"
disabled={!!sshEditing}
/>
<label className="shell-modal-label">{t('shell.host')}</label>
<input
@@ -1508,9 +1535,16 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
onChange={e => setSshForm(f => ({ ...f, key_path: e.target.value }))}
placeholder="~/.ssh/id_rsa"
/>
<label className="shell-modal-label">{t('shell.password')} <span style={{ fontWeight: 400, fontSize: 10, color: 'var(--text-disabled)' }}>({t('shell.passwordHint')})</span></label>
<input
type="password"
value={sshForm.password}
onChange={e => setSshForm(f => ({ ...f, password: e.target.value }))}
placeholder="••••••"
/>
</div>
<div className="shell-modal-footer">
<button className="ghost" onClick={() => setShowSshModal(false)}>{t('shell.cancel')}</button>
<button className="ghost" onClick={() => { setShowSshModal(false); setSshEditing(null) }}>{t('shell.cancel')}</button>
<button className="primary" onClick={saveSSHConnection}>{t('shell.save')}</button>
</div>
</div>

View File

@@ -120,6 +120,8 @@ const en = {
port: 'Port',
user: 'User',
keyPath: 'SSH key path',
password: 'Password',
passwordHint: 'requires sshpass installed',
connect: 'Connect',
save: 'Save',
cancel: 'Cancel',

View File

@@ -120,6 +120,8 @@ const fr = {
port: 'Port',
user: 'Utilisateur',
keyPath: 'Chemin cl\u00e9 SSH',
password: 'Mot de passe',
passwordHint: 'n\u00e9cessite sshpass install\u00e9',
connect: 'Se connecter',
save: 'Enregistrer',
cancel: 'Annuler',

View File

@@ -442,6 +442,9 @@ input::placeholder { color: var(--text-disabled); }
.shell-ai-col { width: 320px; max-width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; }
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; }
.sudo-indicator { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.sudo-indicator.sudo-ok { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.5); }
.sudo-indicator.sudo-blocked { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); }
.shell-analyze-btn {
display: flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: var(--radius);