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
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:
@@ -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) }),
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user