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

@@ -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 || [])
showToast(t('config.upToDate'))
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)
setSshConnections(prev => [...prev, { ...sshForm }])
setSshForm({ name: '', host: '', port: 22, user: '', key_path: '' })
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: '', 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">
<span>Analyste Système</span>
<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);