feat: Remote-Verbindungen – Windows-Workstations per WebSocket steuern

- RemoteConnectionManager: Singleton-Service verwaltet WebSocket-Verbindungen
  mit Command-Execution (Send/Receive mit Timeout + SemaphoreSlim-Locking)
- RemoteEndpoint: WebSocket-Endpoint /api/remote (auth-frei), REST-APIs für
  Connections/Assign/Unassign, PowerShell-Script-Auslieferung mit dynamischer URL
- remote.ps1: PowerShell-Client für Windows – verbindet per WebSocket,
  sendet Hostname/User/OS, empfängt und führt Befehle aus, Heartbeat
- remote_execute Tool: Neues Agent-Tool, nur verfügbar wenn Session eine
  zugeordnete Remote-Verbindung hat, führt PowerShell-Befehle remote aus
- Frontend: Menüpunkt 'Remote-Verbindungen' im Chat-Dropdown, Modal zeigt
  alle aktiven WebSocket-Verbindungen (Hostname, User, OS, Verbindungszeit),
  Verbinden/Trennen-Buttons pro Verbindung und Session
- Auth: /api/remote und /remote.ps1 von Authentifizierung ausgenommen

Architektur: Windows-Client → WebSocket → GOTT-Server → Agent-Tool
Session-gebunden: Eine Verbindung wird einer Session zugeordnet
This commit is contained in:
Meleki committed 2026-03-11 07:04:24 +01:00
1 parent 9f4746c2e7
commit 10da2bd964
14 files changed
+706 -18

No files matched your search

+1
View File
@@ -358,6 +358,7 @@
<div class="header-menu">
<button class="header-menu-trigger icon-btn" id="chatMenuBtn"><i class="fa fa-ellipsis-v"></i></button>
<div class="header-menu-dropdown" id="chatMenuDropdown" hidden>
<button class="header-menu-item" id="remoteBtn"><i class="fa fa-desktop"></i> Remote-Verbindungen</button>
<button class="header-menu-item" id="systemPromptBtn">System-Prompt anzeigen</button>
<button class="header-menu-item" id="archiveBtn">Archivieren</button>
</div>
+25
View File
@@ -629,6 +629,31 @@ async function apiImportOpenclawSession(openclawSessionId, sessionId) {
return resp.json();
}
// ── Remote API ──
async function apiGetRemoteConnections() {
const resp = await fetch('/api/remote/connections');
return resp.json();
}
async function apiAssignRemote(connectionId, sessionId) {
const resp = await fetch('/api/remote/assign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connectionId, sessionId })
});
return resp.json();
}
async function apiUnassignRemote(connectionId) {
const resp = await fetch('/api/remote/unassign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connectionId })
});
return resp.json();
}
// ── Backup API ──
async function apiBackupDb() {
+98
View File
@@ -292,6 +292,104 @@ async function showOpenClawImportModal(sessionId) {
}
}
// ── Remote-Verbindungen Modal ──
async function showRemoteModal(sessionId) {
const { body, close } = _createModal('Remote-Verbindungen');
body.innerHTML = '<div class="sp-loading">Laden...</div>';
async function renderConnections() {
try {
const connections = await apiGetRemoteConnections();
body.innerHTML = '';
if (!connections || connections.length === 0) {
var empty = document.createElement('div');
empty.className = 'sp-loading';
empty.innerHTML = 'Keine Remote-Verbindungen aktiv.<br><br>' +
'<span style="font-size:var(--font-size);opacity:var(--semi-transparent)">Auf dem Windows-PC ausführen:<br>' +
'<code>irm ' + esc(location.origin) + '/remote.ps1 | iex</code></span>';
body.appendChild(empty);
return;
}
var hint = document.createElement('div');
hint.style.cssText = 'font-size:var(--font-size);opacity:var(--semi-transparent);margin-bottom:var(--box-spacing)';
hint.textContent = 'Verbindung auswählen um Remote-Befehle in dieser Session zu ermöglichen:';
body.appendChild(hint);
for (var i = 0; i < connections.length; i++) {
var conn = connections[i];
var card = document.createElement('div');
card.className = 'sp-block';
card.style.cursor = 'pointer';
var isAssigned = conn.assignedSessionId === sessionId;
var isAssignedOther = conn.assignedSessionId && conn.assignedSessionId !== sessionId;
var label = document.createElement('div');
label.className = 'sp-block-label';
var statusIcon = isAssigned ? 'fa-check-circle' : (isAssignedOther ? 'fa-link' : 'fa-desktop');
var statusColor = isAssigned ? 'color:var(--primary)' : '';
label.innerHTML = '<i class="fa ' + statusIcon + '" style="' + statusColor + '"></i> ' +
esc(conn.username || '?') + '@' + esc(conn.hostname || '?');
card.appendChild(label);
var content = document.createElement('div');
content.className = 'sp-block-content';
content.style.cssText = 'font-size:var(--font-size);display:flex;align-items:center;gap:var(--box-spacing)';
var info = document.createElement('span');
info.style.cssText = 'flex:1;opacity:var(--semi-transparent)';
var connDate = new Date(conn.connectedAt);
info.textContent = conn.os + ' • Verbunden seit ' + connDate.toLocaleTimeString('de');
content.appendChild(info);
var btn = document.createElement('button');
if (isAssigned) {
btn.textContent = 'Trennen';
btn.style.cssText = 'color:var(--dangerous)';
} else if (isAssignedOther) {
btn.textContent = 'Andere Session';
btn.disabled = true;
btn.style.cssText = 'opacity:var(--semi-transparent)';
} else {
btn.textContent = 'Verbinden';
btn.style.cssText = 'color:var(--primary)';
}
content.appendChild(btn);
card.appendChild(content);
body.appendChild(card);
// Event-Handler
(function(c, assigned) {
btn.addEventListener('click', async function(e) {
e.stopPropagation();
btn.disabled = true;
try {
if (assigned) {
await apiUnassignRemote(c.id);
} else {
await apiAssignRemote(c.id, sessionId);
}
await renderConnections();
} catch(err) {
showToast('Fehler: ' + err.message, 'error');
btn.disabled = false;
}
});
})(conn, isAssigned);
}
} catch (e) {
body.innerHTML = '<div class="sp-loading" style="color:var(--dangerous)">Fehler beim Laden: ' + esc(e.message) + '</div>';
}
}
await renderConnections();
}
// Event-Delegation für Krea-Thumbnails im Chat
document.addEventListener('click', function(e) {
var ref = e.target.closest('.krea-ref');
+7
View File
@@ -169,6 +169,13 @@ registerPage('chat', {
});
}
// Remote-Verbindungen
const remoteBtn = document.getElementById('remoteBtn');
if (remoteBtn) remoteBtn.addEventListener('click', () => {
if (chatMenuDropdown) chatMenuDropdown.hidden = true;
showRemoteModal(sessionId);
});
// System-Prompt anzeigen
const systemPromptBtn = document.getElementById('systemPromptBtn');
if (systemPromptBtn) systemPromptBtn.addEventListener('click', () => {
+3 -2
View File
@@ -1,12 +1,13 @@
// ── Tool Call Visibility ──
const MODIFICATION_TOOLS = ['write_file', 'edit_file', 'delete_file', 'memory_write'];
const MODIFICATION_TOOLS = ['write_file', 'edit_file', 'delete_file', 'memory_write', 'remote_execute'];
const TOOL_DISPLAY_NAMES = {
write_file: 'Datei schreiben',
edit_file: 'Datei bearbeiten',
delete_file: 'Datei löschen',
memory_write: 'Erinnerungen festhalten',
web_search: 'Web-Suche'
web_search: 'Web-Suche',
remote_execute: 'Remote-Befehl'
};
function isToolVisible(toolName) {
+164
View File
@@ -0,0 +1,164 @@
# GOTT Remote Agent - Windows PowerShell Client
# Verbindet sich per WebSocket zum GOTT-Server und fuehrt Befehle aus.
#
# Verwendung:
# irm https://gott.example.com/remote.ps1 | iex
# oder:
# .\remote.ps1 -Url "wss://gott.example.com/api/remote"
param(
[string]$Url = "{{WS_URL}}"
)
$ErrorActionPreference = "Stop"
Write-Host ""
Write-Host " ============================" -ForegroundColor Cyan
Write-Host " GOTT Remote Agent" -ForegroundColor Cyan
Write-Host " ============================" -ForegroundColor Cyan
Write-Host ""
Write-Host " Verbinde zu: $Url" -ForegroundColor Gray
Write-Host ""
try {
$ws = New-Object System.Net.WebSockets.ClientWebSocket
$ct = New-Object System.Threading.CancellationToken
# Verbindung aufbauen
$ws.ConnectAsync([Uri]$Url, $ct).GetAwaiter().GetResult()
Write-Host " [OK] Verbunden!" -ForegroundColor Green
# Hello-Message senden
$hostname = $env:COMPUTERNAME
$username = $env:USERNAME
$osVersion = [System.Environment]::OSVersion.VersionString
$hello = @{
type = "hello"
hostname = $hostname
username = $username
os = $osVersion
} | ConvertTo-Json -Compress
$helloBytes = [System.Text.Encoding]::UTF8.GetBytes($hello)
$segment = New-Object System.ArraySegment[byte] -ArgumentList @(,$helloBytes)
$ws.SendAsync($segment, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $ct).GetAwaiter().GetResult()
Write-Host " Host: $username@$hostname ($osVersion)" -ForegroundColor Gray
Write-Host ""
Write-Host " Warte auf Befehle... (Strg+C zum Beenden)" -ForegroundColor Yellow
Write-Host ""
# Empfangs-Loop
$buffer = New-Object byte[] 65536
$messageBuffer = New-Object System.IO.MemoryStream
# Heartbeat-Timer (alle 30 Sekunden)
$lastPing = [DateTime]::UtcNow
while ($ws.State -eq [System.Net.WebSockets.WebSocketState]::Open) {
# Heartbeat senden
if (([DateTime]::UtcNow - $lastPing).TotalSeconds -gt 30) {
$pingBytes = [System.Text.Encoding]::UTF8.GetBytes('{"type":"ping"}')
$pingSegment = New-Object System.ArraySegment[byte] -ArgumentList @(,$pingBytes)
try {
$ws.SendAsync($pingSegment, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $ct).GetAwaiter().GetResult()
} catch {
break
}
$lastPing = [DateTime]::UtcNow
}
# Auf Nachricht warten (mit Timeout fuer Heartbeat)
$receiveTask = $ws.ReceiveAsync((New-Object System.ArraySegment[byte] -ArgumentList @(,$buffer)), $ct)
if (-not $receiveTask.Wait(5000)) {
continue
}
$result = $receiveTask.Result
if ($result.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) {
Write-Host " Server hat Verbindung geschlossen." -ForegroundColor Yellow
break
}
$messageBuffer.SetLength(0)
$messageBuffer.Write($buffer, 0, $result.Count)
# Mehrteilige Nachrichten zusammenbauen
while (-not $result.EndOfMessage) {
$result = $ws.ReceiveAsync((New-Object System.ArraySegment[byte] -ArgumentList @(,$buffer)), $ct).GetAwaiter().GetResult()
$messageBuffer.Write($buffer, 0, $result.Count)
}
$json = [System.Text.Encoding]::UTF8.GetString($messageBuffer.ToArray())
$msg = $json | ConvertFrom-Json
switch ($msg.type) {
"execute" {
$command = $msg.command
$timestamp = Get-Date -Format "HH:mm:ss"
Write-Host " [$timestamp] Ausfuehren: " -NoNewline -ForegroundColor Cyan
Write-Host $command -ForegroundColor White
$exitCode = 0
$stdout = ""
$stderr = ""
try {
# PowerShell-Befehl ausfuehren
$output = & powershell.exe -NoProfile -NonInteractive -Command $command 2>&1
$stdout = ($output | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] }) -join "`n"
$stderrLines = ($output | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) | ForEach-Object { $_.ToString() }
$stderr = $stderrLines -join "`n"
$exitCode = $LASTEXITCODE
if ($null -eq $exitCode) { $exitCode = 0 }
} catch {
$stderr = $_.Exception.Message
$exitCode = 1
}
# Ergebnis senden
$response = @{
type = "result"
exitCode = $exitCode
output = if ($stdout) { $stdout } else { "" }
error = if ($stderr) { $stderr } else { "" }
} | ConvertTo-Json -Compress
$responseBytes = [System.Text.Encoding]::UTF8.GetBytes($response)
$responseSegment = New-Object System.ArraySegment[byte] -ArgumentList @(,$responseBytes)
$ws.SendAsync($responseSegment, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $ct).GetAwaiter().GetResult()
# Kurzausgabe
if ($stdout) {
$preview = if ($stdout.Length -gt 200) { $stdout.Substring(0, 200) + "..." } else { $stdout }
Write-Host " -> $preview" -ForegroundColor Gray
}
if ($stderr) {
Write-Host " [stderr] $stderr" -ForegroundColor Red
}
Write-Host " [Exit: $exitCode]" -ForegroundColor $(if ($exitCode -eq 0) { "Green" } else { "Red" })
Write-Host ""
}
"pong" {
# Heartbeat-Antwort – ignorieren
}
default {
Write-Host " Unbekannte Nachricht: $($msg.type)" -ForegroundColor Yellow
}
}
}
} catch [System.Net.WebSockets.WebSocketException] {
Write-Host " Verbindung verloren." -ForegroundColor Red
} catch {
Write-Host " Fehler: $_" -ForegroundColor Red
} finally {
if ($ws -and $ws.State -eq [System.Net.WebSockets.WebSocketState]::Open) {
try {
$ws.CloseAsync([System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure, "bye", $ct).GetAwaiter().GetResult()
} catch { }
}
if ($ws) { $ws.Dispose() }
Write-Host ""
Write-Host " Verbindung beendet." -ForegroundColor Yellow
}