Security-Fixes + UI-Verbesserungen: Stop-Button, Textfeld, Agent-Filter
All checks were successful
Build AppImage / build (push) Has been skipped

Backend:
- Credentials aus Code entfernt → ENV-Variablen mit Fallback
- File-Traversal in Update-Download verhindert (Path-Sanitization)
- CLI-Injection bei D-Bus mit Whitelist-Validierung abgesichert

Frontend:
- Stop-Button dezenter (kleinere Schrift, gedämpftes Rot, kein Pulsieren)
- Stop löscht keine Session/Messages mehr — nur Agents stoppen
- Textfeld nicht mehr blockiert während Claude arbeitet (Einwände möglich)
- Agent-Filter "Nur aktive" wird in localStorage persistent gespeichert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-20 03:18:39 +02:00
parent 427fa858a9
commit 3993387977
8 changed files with 192 additions and 44 deletions

139
README.md
View file

@ -2,6 +2,145 @@
Eigenständige Desktop-Anwendung die Claude Code als Backend nutzt, mit nativem UI, Live-Übersicht und kontrolliertem OS-Zugriff. Eigenständige Desktop-Anwendung die Claude Code als Backend nutzt, mit nativem UI, Live-Übersicht und kontrolliertem OS-Zugriff.
## Status (Stand 2026-04-20)
**Variante A (Native Desktop-App) ist umgesetzt** — Tauri 2.0 + SvelteKit 5, Phase 1-13 fertig (siehe [ROADMAP.md](ROADMAP.md)). Variante B (autonome VM) bleibt Vision.
Was läuft:
- 4-Panel-Layout, 24 UI-Komponenten (Chat, Activity, Memory, Audit, Knowledge, Voice, Hooks, IDE, Programs, Performance, Settings, …)
- 16 Rust-Backend-Module (`claude.rs`, `db.rs`, `guard.rs`, `memory.rs`, `voice.rs`, `hooks.rs`, `ide.rs`, …)
- Sprach-Interface mit Push-to-Talk (`VoicePanel`), Whisper STT
- Modell-Auswahl Haiku/Sonnet/Opus, Token-/Kosten-Anzeige
- Subagent-Hierarchie, Multi-Agent-Modi, Hook-System
- Session-Persistenz (SQLite), Audit-Log
- VS-Code-Extension `claude-desktop-bridge` (steuert VSCodium aus der App heraus, WebSocket-Port 7890)
- CI/CD-Pipeline (Forgejo Actions) → AppImage in Package-Registry
## Installation
### AppImage (Debian/Ubuntu/Fedora/Arch — **nicht NixOS**)
```bash
mkdir -p ~/Applications
curl -sSL -o ~/Applications/Claude-Desktop.AppImage \
-u "token:<dein-forgejo-token>" \
'https://git.data-it-solution.de/api/packages/data/generic/claude-desktop/latest/Claude-Desktop_0.1.0_amd64.AppImage'
chmod +x ~/Applications/Claude-Desktop.AppImage
~/Applications/Claude-Desktop.AppImage
```
Auf NixOS hat das AppImage einen WebKit2GTK ↔ Mesa ABI-Konflikt → siehe nächster Abschnitt.
### NixOS — nativer Build (Pflicht)
Das AppImage läuft auf NixOS nicht (KB-Eintrag #381 zur Diagnose). Stattdessen lokal bauen mit der mitgelieferten `shell.nix`:
```bash
cd "/mnt/17 - Entwicklungen/20 - Projekte/ClaudeDesktop"
# Cargo-Target auf lokales tmpfs (SMB-Mount macht Cargo-Build-Errors)
CARGO_TARGET_DIR=/tmp/claude-target \
nix-shell shell.nix --run 'npm ci && npm run tauri build -- --bundles appimage'
# Tauri-Bundling kann an pkg-config-Bug scheitern — Binary reicht aber:
nix-shell shell.nix --run /tmp/claude-target/release/claude-desktop
```
Permanenter Wrapper:
```bash
cat > ~/.local/bin/claude-desktop <<'EOF'
#!/usr/bin/env bash
cd "/mnt/17 - Entwicklungen/20 - Projekte/ClaudeDesktop"
exec nix-shell shell.nix --run /tmp/claude-target/release/claude-desktop
EOF
chmod +x ~/.local/bin/claude-desktop
```
### Development
```bash
cd "/mnt/17 - Entwicklungen/20 - Projekte/ClaudeDesktop"
nix-shell shell.nix --run 'npm ci && npm run tauri:dev' # mit Hot-Reload
```
## CI/CD-Pipeline
Workflow: `.forgejo/workflows/build-appimage.yml`
| Trigger | Was passiert |
|---|---|
| Push auf `main` mit `[appimage]` in Commit-Message | AppImage bauen + in Package-Registry hochladen |
| Push eines Tags `v*` | zusätzlich als Release-Asset anhängen |
**Runner:** `16-Forgejo-Runner-AppImage` (Debian Bookworm, glibc, mit linuxdeploy + appimagetool + Tauri-Build-Stack vorinstalliert). Der Standard-Alpine-Runner kann Tauri-AppImages nicht bauen (musl-Inkompatibilität von linuxdeploy). Setup des Runners: KB-Eintrag #371.
**Workflow-Eigenheiten** (gut zu wissen wenn was ändern):
- AppImage-Filename mit Leerzeichen (`Claude Desktop_…`) wird vor Upload zu `Claude-Desktop_…` umbenannt (curl-URL-Bug)
- Vor jedem Upload werden alte Versionen gelöscht (Forgejo Package-Registry weist PUT auf existierenden Pfad mit HTTP 409 ab)
- Custom-AppRun mit NixOS-Detection wird nach `tauri build` eingesetzt + mit `appimagetool` re-bundled. Der Hook `apprun-hooks/linuxdeploy-plugin-gtk.sh` darf dabei nicht überschrieben werden (KB #384), sonst finden WebKit-Subprozesse ihre Helpers nicht
- Ntfy-Notifications (Build-Start/Success/Failure) — Topic `vk-builds`
Ntfy-Setup für andere Projekte: KB #190/#191/#220.
## VS-Code-Extension `claude-desktop-bridge`
Eigenes Subprojekt unter [vscode-extension/](vscode-extension/). Ermöglicht Claude Desktop, VSCodium fernzusteuern (Datei öffnen, Cursor-Position setzen, Terminal-Befehle absetzen) über einen WebSocket-Server (Port 7890 default).
```bash
cd vscode-extension
npm ci && npm run compile
# Verpacktes vsix: claude-desktop-bridge-0.1.0.vsix
codium --install-extension claude-desktop-bridge-0.1.0.vsix
```
Befehle in VSCodium: `Claude Desktop: Verbindung starten/beenden`. Die App meldet sich beim Start automatisch.
## Projektstruktur (Stand)
```
ClaudeDesktop/
├── src-tauri/src/ # 16 Rust-Module
│ ├── main.rs # Entry (setzt WEBKIT_DISABLE_*-Defaults für Linux)
│ ├── lib.rs # Tauri-App-Setup
│ ├── claude.rs # Claude Agent SDK Integration
│ ├── db.rs # MySQL (claude-DB) + SQLite-Persistierung
│ ├── guard.rs # Guard-Rails (Critical/Moderate/Safe)
│ ├── hooks.rs # Hook-System
│ ├── memory.rs # Memory/Knowledge-Graph
│ ├── voice.rs # Whisper STT + Voice-Activity-Detection
│ ├── ide.rs # Bridge zur VSCodium-Extension
│ ├── audit.rs, knowledge.rs, programs.rs, session.rs,
│ │ teaching.rs, update.rs, context.rs
│ └── ...
├── src/ # SvelteKit Frontend
│ ├── routes/+layout.svelte
│ ├── routes/+page.svelte
│ ├── routes/presentation/+page.svelte
│ └── lib/components/ # 24 UI-Panels
├── vscode-extension/ # VSCodium-Bridge
├── .forgejo/workflows/ # CI/CD
├── shell.nix # NixOS Dev-Shell
├── ROADMAP.md # Phase-Status
├── TEST-ROADMAP.md # Test-Plan
├── tools.yaml # MCP-Tool-Inventar
└── package.json
```
## Wissensbasis-Referenzen (für künftige Sessions)
| KB-ID | Thema |
|---|---|
| #311 | Diese Pipeline (Workflow-Tricks) |
| #371 | Debian Forgejo-Runner Setup |
| #372 | libssl-dev für openssl-sys |
| #381 | NixOS WebKit2GTK EGL-Crash → native build |
| #382 | Cargo auf SMB → CARGO_TARGET_DIR umleiten |
| #384 | Custom-AppRun + linuxdeploy-Hook |
| #248 | Tauri 2.0 + SvelteKit auf NixOS shell.nix |
---
## Motivation ## Motivation
Claude Code in VSCodium funktioniert, hat aber Grenzen: Claude Code in VSCodium funktioniert, hat aber Grenzen:

View file

@ -5,12 +5,13 @@ use mysql_async::{Pool, prelude::*};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
/// Verbindungskonfiguration /// Verbindungskonfiguration — aus ENV-Variablen, Fallback auf Defaults
const MYSQL_HOST: &str = "192.168.155.11";
const MYSQL_PORT: u16 = 3306; const MYSQL_PORT: u16 = 3306;
const MYSQL_USER: &str = "claude";
const MYSQL_PASS: &str = "8715"; fn mysql_host() -> String { std::env::var("CLAUDE_MYSQL_HOST").unwrap_or_else(|_| "192.168.155.11".to_string()) }
const MYSQL_DB: &str = "claude"; fn mysql_user() -> String { std::env::var("CLAUDE_MYSQL_USER").unwrap_or_else(|_| "claude".to_string()) }
fn mysql_pass() -> String { std::env::var("CLAUDE_MYSQL_PASS").unwrap_or_else(|_| "8715".to_string()) }
fn mysql_db() -> String { std::env::var("CLAUDE_MYSQL_DB").unwrap_or_else(|_| "claude".to_string()) }
/// Wissenseintrag aus der knowledge-Tabelle /// Wissenseintrag aus der knowledge-Tabelle
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -59,7 +60,7 @@ pub struct CategoryInfo {
fn create_pool() -> Pool { fn create_pool() -> Pool {
let url = format!( let url = format!(
"mysql://{}:{}@{}:{}/{}", "mysql://{}:{}@{}:{}/{}",
MYSQL_USER, MYSQL_PASS, MYSQL_HOST, MYSQL_PORT, MYSQL_DB mysql_user(), mysql_pass(), mysql_host(), MYSQL_PORT, mysql_db()
); );
Pool::new(url.as_str()) Pool::new(url.as_str())
} }

View file

@ -26,6 +26,17 @@ pub async fn dbus_call(
) -> Result<DbusCallResult, String> { ) -> Result<DbusCallResult, String> {
let bus = if session.unwrap_or(true) { "--session" } else { "--system" }; let bus = if session.unwrap_or(true) { "--session" } else { "--system" };
// Whitelist-Validierung gegen CLI-Injection
if !service.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '_' || c == '-') {
return Err("Ungültiger Service-Name".to_string());
}
if !path.chars().all(|c| c.is_alphanumeric() || c == '/' || c == '.' || c == '_' || c == '-') {
return Err("Ungültiger D-Bus Pfad".to_string());
}
if !method.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '_' || c == '-') {
return Err("Ungültiger Methodenname".to_string());
}
let mut cmd = Command::new("dbus-send"); let mut cmd = Command::new("dbus-send");
cmd.arg("--print-reply") cmd.arg("--print-reply")
.arg(bus) .arg(bus)

View file

@ -142,6 +142,11 @@ pub async fn download_update(
std::fs::create_dir_all(&cache_dir).ok(); std::fs::create_dir_all(&cache_dir).ok();
let file_name = download_url.split('/').last().unwrap_or("update.AppImage"); let file_name = download_url.split('/').last().unwrap_or("update.AppImage");
// Sanitize: Nur Basename, keine Pfad-Traversal (z.B. "../böse.sh")
let file_name = std::path::Path::new(file_name)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("update.AppImage");
let download_path = cache_dir.join(file_name); let download_path = cache_dir.join(file_name);
let mut file = std::fs::File::create(&download_path) let mut file = std::fs::File::create(&download_path)

View file

@ -3,7 +3,11 @@
import type { Agent } from '$lib/stores/app'; import type { Agent } from '$lib/stores/app';
import { derived } from 'svelte/store'; import { derived } from 'svelte/store';
let onlyActive = $state(false); // Filter-State persistent in localStorage speichern
let onlyActive = $state(localStorage.getItem('agentFilter_onlyActive') === 'true');
$effect(() => {
localStorage.setItem('agentFilter_onlyActive', String(onlyActive));
});
// Gefilterter Tree: wenn onlyActive, dann nur Agents mit status==='active' // Gefilterter Tree: wenn onlyActive, dann nur Agents mit status==='active'
const filteredTree = derived([agentTree], ([$tree]) => { const filteredTree = derived([agentTree], ([$tree]) => {

View file

@ -393,7 +393,7 @@
async function sendMessage() { async function sendMessage() {
const text = $currentInput.trim(); const text = $currentInput.trim();
if (!text || $isProcessing) return; if (!text) return;
// Auto-Session erstellen falls keine aktiv // Auto-Session erstellen falls keine aktiv
let sessionId = get(currentSessionId); let sessionId = get(currentSessionId);
@ -747,7 +747,7 @@
bind:value={$currentInput} bind:value={$currentInput}
onkeydown={handleKeydown} onkeydown={handleKeydown}
placeholder="Nachricht eingeben... (Ctrl+K = Focus, Ctrl+Enter = Senden)" placeholder="Nachricht eingeben... (Ctrl+K = Focus, Ctrl+Enter = Senden)"
disabled={$isProcessing || isRecording} disabled={isRecording}
rows="3" rows="3"
></textarea> ></textarea>
<div class="input-buttons"> <div class="input-buttons">
@ -768,7 +768,7 @@
<button <button
class="send-button" class="send-button"
onclick={sendMessage} onclick={sendMessage}
disabled={!$currentInput.trim() || $isProcessing || isRecording} disabled={!$currentInput.trim() || isRecording}
> >
{#if $isProcessing} {#if $isProcessing}

View file

@ -30,30 +30,27 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--spacing-md); gap: var(--spacing-xs);
padding: var(--spacing-md) var(--spacing-lg); padding: var(--spacing-xs) var(--spacing-sm);
background: linear-gradient(135deg, var(--error), #c0392b); background: #c53030;
color: white; color: rgba(255, 255, 255, 0.85);
font-size: 1rem; font-size: 0.8rem;
font-weight: 700; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.02em;
letter-spacing: 0.05em; border-radius: var(--radius-sm);
border-radius: var(--radius-md); border: 1px solid rgba(197, 48, 48, 0.6);
border: 2px solid var(--error);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.15s ease;
box-shadow: 0 4px 15px rgba(218, 68, 83, 0.3);
} }
.stop-button:not(.disabled):hover { .stop-button:not(.disabled):hover {
background: linear-gradient(135deg, #e74c3c, var(--error)); background: #b52828;
transform: translateY(-1px); color: white;
box-shadow: 0 6px 20px rgba(218, 68, 83, 0.4); border-color: rgba(197, 48, 48, 0.8);
} }
.stop-button:not(.disabled):active { .stop-button:not(.disabled):active {
transform: translateY(0); background: #a02020;
box-shadow: 0 2px 10px rgba(218, 68, 83, 0.3);
} }
.stop-button.disabled { .stop-button.disabled {
@ -61,26 +58,15 @@
border-color: var(--bg-tertiary); border-color: var(--bg-tertiary);
color: var(--text-secondary); color: var(--text-secondary);
cursor: not-allowed; cursor: not-allowed;
box-shadow: none;
} }
.stop-icon { .stop-icon {
font-size: 1.25rem; font-size: 0.875rem;
} }
.stop-hint { .stop-hint {
font-size: 0.75rem; font-size: 0.65rem;
font-weight: 400; font-weight: 400;
opacity: 0.8; opacity: 0.65;
}
/* Animierter Rand wenn aktiv */
.stop-button:not(.disabled) {
animation: border-pulse 1.5s ease-in-out infinite;
}
@keyframes border-pulse {
0%, 100% { border-color: var(--error); }
50% { border-color: #e88; }
} }
</style> </style>

View file

@ -15,7 +15,6 @@ import {
updateAgentStatus, updateAgentStatus,
addToolCall, addToolCall,
completeToolCall, completeToolCall,
clearAll,
currentModel, currentModel,
sessionStats, sessionStats,
contextUsage, contextUsage,
@ -333,12 +332,15 @@ export async function initEventListeners(): Promise<void> {
}) })
); );
// STOPP-Signal // STOPP-Signal — nur Agents stoppen, Messages/Session bleiben erhalten
listeners.push( listeners.push(
await listen('agents-stopped', () => { await listen('agents-stopped', () => {
console.log('🛑 STOPP-Signal empfangen'); console.log('🛑 STOPP-Signal empfangen');
streamingMessageId = null; streamingMessageId = null;
clearAll(); // Alle Agents auf "stopped" setzen, aber Messages NICHT löschen
agents.update((ags) => ags.map((a) => ({ ...a, status: 'stopped' as const })));
toolCalls.set([]);
isProcessing.set(false);
}) })
); );