Watch
1
0
Fork
You've already forked RedFlag
0

feat: FEAT-002 local agent API, desktop tray spine, screenshot handler

Slices 1-3 of the local agent IPC surface:
- Read model (local_status.go, loop wired): update counts, scanner status,
  check-in state, token receipt counts — no token material exposed
- Local IPC (localapi/): Unix socket (group=redflag-local, 0660) + Windows
  named pipe (SDDL: LocalSystem/Admins/RedFlagLocal); five read-only endpoints
- `redflag-agent -local-status` CLI probe of the local API surface
- Screenshot capture handler (screenshot.go, dispatch wired)
- Tauri desktop spine (desktop/): tray icon, left-click window, local IPC reader
- Desktop React entry (web/src/desktop/LocalAgentApp.tsx, index.desktop.html,
  vite.desktop.config.ts)
- Installer group provisioning: linux.sh creates redflag-local, sets
  SupplementaryGroups; windows.ps1 creates RedFlagLocal security group
- Server-side: screenshot receipt handler on agents, updates handler additions
- web/package.json: @tauri-apps/api + tauri CLI dev dep added
This commit is contained in:
Fimeg 2026-06-08 17:08:58 -04:00
commit ffffe9b956
35 changed files with 1996 additions and 31 deletions

5
.gitignore vendored
View file

@ -152,6 +152,8 @@ out
# Nuxt.js build / generate output
.nuxt
dist
web/dist-desktop/
desktop/target/
# vuepress build output
.vuepress/dist
@ -473,6 +475,9 @@ docker-compose.dev.yml
.migration_temp/
# =============================================================================
# Root-level stray npm artifact (npm should be run from web/)
/package-lock.json
# Kate editor swap files
# =============================================================================
*.swp

View file

@ -14,28 +14,29 @@ import (
// CLIFlags holds all command-line flags
type CLI struct {
Register bool
Scan bool
Status bool
ListUpdates bool
Version bool
ServerURL string
Token string
ProxyHTTP string
ProxyHTTPS string
ProxyNoProxy string
LogLevel string
ConfigFile string
Tags string
Organization string
DisplayName string
InsecureTLS bool
ExportFormat string
InstallService bool
RemoveService bool
StartService bool
StopService bool
ServiceStatus bool
Register bool
Scan bool
Status bool
LocalStatus bool
ListUpdates bool
Version bool
ServerURL string
Token string
ProxyHTTP string
ProxyHTTPS string
ProxyNoProxy string
LogLevel string
ConfigFile string
Tags string
Organization string
DisplayName string
InsecureTLS bool
ExportFormat string
InstallService bool
RemoveService bool
StartService bool
StopService bool
ServiceStatus bool
}
// ParseFlags parses all command-line flags and returns the CLI struct
@ -46,6 +47,7 @@ func ParseFlags() *CLI {
flag.BoolVar(&cli.Register, "register", false, "Register agent with server")
flag.BoolVar(&cli.Scan, "scan", false, "Scan for updates and display locally")
flag.BoolVar(&cli.Status, "status", false, "Show agent status")
flag.BoolVar(&cli.LocalStatus, "local-status", false, "Show live local agent status over local IPC")
flag.BoolVar(&cli.ListUpdates, "list-updates", false, "List detailed update information")
flag.BoolVar(&cli.Version, "version", false, "Show version information")
flag.StringVar(&cli.ServerURL, "server", "", "Server URL")

View file

@ -0,0 +1,119 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/localapi"
)
// HandleLocalStatusCommand displays live agent status through the local IPC API.
// It intentionally runs before config loading, so membership in the local access
// group is enough to inspect local state without reading protected config files.
func HandleLocalStatusCommand(exportFormat string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
snapshot, err := localapi.FetchSnapshot(ctx, localapi.ClientOptions{})
if err != nil {
return err
}
if exportFormat == "json" {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(snapshot)
}
printLocalStatus(snapshot)
return nil
}
func printLocalStatus(snapshot *localapi.Snapshot) {
identity := snapshot.Identity
status := snapshot.Status
fmt.Println("==================================================================")
fmt.Println("RedFlag Local Agent Status")
fmt.Println("==================================================================")
fmt.Printf("Agent ID: %s\n", identity.AgentID)
fmt.Printf("Server: %s\n", identity.ServerURL)
if identity.Hostname != "" {
fmt.Printf("Hostname: %s\n", identity.Hostname)
}
if identity.OSType != "" {
fmt.Printf("OS Type: %s\n", identity.OSType)
}
if identity.DisplayName != "" {
fmt.Printf("Display Name: %s\n", identity.DisplayName)
}
if len(identity.Tags) > 0 {
fmt.Printf("Tags: %s\n", strings.Join(identity.Tags, ", "))
}
fmt.Printf("Version: %s\n", identity.AgentVersion)
fmt.Printf("Registered: %t\n", identity.Registered)
fmt.Println()
fmt.Printf("Agent Status: %s\n", fallback(status.AgentStatus, "unknown"))
if !status.LastCheckIn.IsZero() {
fmt.Printf("Last Check-in: %s\n", status.LastCheckIn.Format(time.RFC3339))
}
if !status.LastScan.IsZero() {
fmt.Printf("Last Scan: %s\n", status.LastScan.Format(time.RFC3339))
}
fmt.Printf("Updates Available: %d\n", status.UpdateCount)
fmt.Printf("Summary Total: %d\n", status.Summary.Total)
if len(status.Summary.ByEcosystem) > 0 {
fmt.Printf("By Ecosystem: %s\n", formatCounts(status.Summary.ByEcosystem))
}
if len(status.Summary.BySeverity) > 0 {
fmt.Printf("By Severity: %s\n", formatCounts(status.Summary.BySeverity))
}
if len(status.Scanners) > 0 {
fmt.Println()
fmt.Println("Scanners:")
names := make([]string, 0, len(status.Scanners))
for name := range status.Scanners {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
scanner := status.Scanners[name]
line := fmt.Sprintf(" - %s: %s (%d updates)", scanner.Name, fallback(scanner.Status, "unknown"), scanner.UpdateCount)
if scanner.LastError != "" {
line += fmt.Sprintf(" error=%q", scanner.LastError)
}
fmt.Println(line)
}
}
fmt.Println("==================================================================")
}
func formatCounts(counts map[string]int) string {
keys := make([]string, 0, len(counts))
for key := range counts {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", key, counts[key]))
}
return strings.Join(parts, ", ")
}
func fallback(value, replacement string) string {
if value == "" {
return replacement
}
return value
}

View file

@ -41,6 +41,13 @@ func main() {
HandleVersionCommand()
}
if cli.LocalStatus {
if err := HandleLocalStatusCommand(cli.ExportFormat); err != nil {
log.Fatal("Local status command failed:", err)
}
return
}
// Handle Windows service management commands
if HandleWindowsServiceCommands(cli) {
return

View file

@ -18,6 +18,7 @@ import (
"github.com/Fimeg/RedFlag/agent/internal/handlers"
"github.com/Fimeg/RedFlag/agent/internal/integrations"
"github.com/Fimeg/RedFlag/agent/internal/kernel"
"github.com/Fimeg/RedFlag/agent/internal/localapi"
"github.com/Fimeg/RedFlag/agent/internal/logging"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
@ -171,6 +172,13 @@ func RunPollingLoop(loopCtx *LoopContext) error {
ctx := loopCtx
localAPIServer, err := localapi.Start(localapi.Options{Config: ctx.Cfg})
if err != nil {
log.Printf("[ERROR] [agent] [localapi] start_failed error=%v", err)
} else {
defer localAPIServer.Stop()
}
// Start kernel enforcement enforcer
if ctx.KernelEnforcer != nil {
if err := ctx.KernelEnforcer.Start(ctx.Ctx); err != nil {

View file

@ -74,6 +74,8 @@ func DispatchCrossPlatformCommand(
err = HandleUpdateAgent(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
case "reboot":
err = HandleReboot(apiClient, cfg, ackTracker, cmd.ID, cmd.Params)
case "capture_screenshot":
err = HandleCaptureScreenshot(apiClient, cfg, ackTracker, cmd.ID)
default:
return false
}

View file

@ -0,0 +1,139 @@
package handlers
import (
"context"
"encoding/base64"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
)
// HandleCaptureScreenshot captures the current display output and reports it
// back as a base64-encoded PNG in the command result's stdout field.
// Observe-only: reads the screen, does not interact with it.
//
// Linux: uses scrot (X11) or import (ImageMagick) as fallback.
// Windows: uses PowerShell with .NET System.Drawing.
// The temp file is cleaned up after encoding.
func HandleCaptureScreenshot(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, commandID string) error {
start := time.Now()
tmpPath := filepath.Join(os.TempDir(), fmt.Sprintf("redflag_screenshot_%d.png", time.Now().UnixNano()))
defer os.Remove(tmpPath)
if err := captureScreen(tmpPath); err != nil {
return fmt.Errorf("screen capture failed: %w", err)
}
data, err := os.ReadFile(tmpPath)
if err != nil {
return fmt.Errorf("failed to read screenshot: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(data)
logReport := client.LogReport{
CommandID: commandID,
Action: "capture_screenshot",
Result: "success",
Stdout: encoded,
ExitCode: 0,
DurationSeconds: int(time.Since(start).Seconds()),
}
if err := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
return fmt.Errorf("failed to report screenshot: %w", err)
}
log.Printf("[INFO] [agent] [screenshot] captured size_bytes=%d duration=%s",
len(data), time.Since(start).Round(time.Millisecond))
return nil
}
// captureScreen writes a PNG screenshot to outputPath. Platform-specific.
func captureScreen(outputPath string) error {
switch runtime.GOOS {
case "linux":
return captureScreenLinux(outputPath)
case "windows":
return captureScreenWindows(outputPath)
default:
return fmt.Errorf("screenshot not supported on %s", runtime.GOOS)
}
}
// captureScreenLinux captures the display using scrot (X11) with import
// (ImageMagick) as fallback. Both are read-only and do not interact with
// the display server.
func captureScreenLinux(outputPath string) error {
// Try scrot first — most common on Fedora/Ubuntu with X11.
if _, err := exec.LookPath("scrot"); err == nil {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "scrot", "-o", outputPath)
if out, err := cmd.CombinedOutput(); err != nil {
log.Printf("[WARN] [agent] [screenshot] scrot_failed output=%q error=%v", string(out), err)
} else {
return nil
}
}
// Fallback: ImageMagick import — also X11, widely available.
if _, err := exec.LookPath("import"); err == nil {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "import", "-window", "root", outputPath)
if out, err := cmd.CombinedOutput(); err != nil {
log.Printf("[WARN] [agent] [screenshot] import_failed output=%q error=%v", string(out), err)
} else {
return nil
}
}
// Fallback: grim (Wayland native).
if _, err := exec.LookPath("grim"); err == nil {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "grim", outputPath)
if out, err := cmd.CombinedOutput(); err != nil {
log.Printf("[WARN] [agent] [screenshot] grim_failed output=%q error=%v", string(out), err)
} else {
return nil
}
}
return fmt.Errorf("no screenshot tool found (tried scrot, import, grim)")
}
// captureScreenWindows captures the display using PowerShell + .NET
// System.Drawing. No extra tools required — always available on Windows.
func captureScreenWindows(outputPath string) error {
psScript := fmt.Sprintf(`
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
$bmp = New-Object System.Drawing.Bitmap($bounds.Width, $bounds.Height)
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
$gfx.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
$bmp.Save('%s', [System.Drawing.Imaging.ImageFormat]::Png)
$gfx.Dispose()
$bmp.Dispose()
`, outputPath)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", psScript)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("powershell screenshot failed: %s: %w", string(out), err)
}
return nil
}

View file

@ -0,0 +1,86 @@
package localapi
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"time"
)
const localHTTPBaseURL = "http://redflag.local"
// ClientOptions configures local IPC client access. Defaults mirror the agent
// listener and remain platform-specific.
type ClientOptions struct {
UnixSocketPath string
WindowsPipeName string
Timeout time.Duration
}
// Snapshot is the compact local state needed by status probes and tray shells.
type Snapshot struct {
Identity IdentityResponse `json:"identity"`
Status StatusResponse `json:"status"`
}
// FetchSnapshot reads the local agent API over the platform IPC transport.
func FetchSnapshot(ctx context.Context, opts ClientOptions) (*Snapshot, error) {
httpClient := newLocalHTTPClient(opts)
identity, err := fetchJSON[IdentityResponse](ctx, httpClient, "/v1/identity")
if err != nil {
return nil, fmt.Errorf("fetch identity: %w", err)
}
status, err := fetchJSON[StatusResponse](ctx, httpClient, "/v1/status")
if err != nil {
return nil, fmt.Errorf("fetch status: %w", err)
}
return &Snapshot{
Identity: identity,
Status: status,
}, nil
}
func newLocalHTTPClient(opts ClientOptions) *http.Client {
timeout := opts.Timeout
if timeout == 0 {
timeout = 5 * time.Second
}
return &http.Client{
Timeout: timeout,
Transport: &http.Transport{
DialContext: dialLocalContext(opts),
DisableKeepAlives: true,
Proxy: nil,
},
}
}
func fetchJSON[T any](ctx context.Context, httpClient *http.Client, path string) (T, error) {
var value T
req, err := http.NewRequestWithContext(ctx, http.MethodGet, localHTTPBaseURL+path, nil)
if err != nil {
return value, err
}
resp, err := httpClient.Do(req)
if err != nil {
return value, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return value, fmt.Errorf("local API returned %s", resp.Status)
}
if err := json.NewDecoder(resp.Body).Decode(&value); err != nil {
return value, err
}
return value, nil
}
func dialLocalContext(opts ClientOptions) func(context.Context, string, string) (net.Conn, error) {
return platformDialLocalContext(opts)
}

View file

@ -0,0 +1,20 @@
//go:build !windows
package localapi
import (
"context"
"net"
)
func platformDialLocalContext(opts ClientOptions) func(context.Context, string, string) (net.Conn, error) {
path := opts.UnixSocketPath
if path == "" {
path = defaultUnixSocketPath()
}
return func(ctx context.Context, _, _ string) (net.Conn, error) {
var dialer net.Dialer
return dialer.DialContext(ctx, "unix", path)
}
}

View file

@ -0,0 +1,63 @@
//go:build !windows
package localapi
import (
"context"
"errors"
"net"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/Fimeg/RedFlag/agent/internal/cache"
)
func TestFetchSnapshotOverUnixSocket(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "redflag-agent.sock")
ln, err := net.Listen("unix", socketPath)
if err != nil {
if errors.Is(err, syscall.EPERM) {
t.Skipf("unix sockets are not permitted in this sandbox: %v", err)
}
t.Fatalf("listen unix socket: %v", err)
}
cfg := testConfig(t)
server, err := Start(Options{
Config: cfg,
LoadCache: func() (*cache.LocalCache, error) {
return &cache.LocalCache{
AgentStatus: "online",
UpdateCount: 7,
Summary: cache.UpdateSummary{
Total: 7,
},
}, nil
},
RequestLog: t.Logf,
ListenerOverride: ln,
})
if err != nil {
t.Fatalf("start local API: %v", err)
}
defer server.Stop()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
snapshot, err := FetchSnapshot(ctx, ClientOptions{UnixSocketPath: socketPath})
if err != nil {
t.Fatalf("fetch snapshot: %v", err)
}
if snapshot.Identity.AgentID != cfg.AgentID.String() {
t.Fatalf("agent_id = %q, want %q", snapshot.Identity.AgentID, cfg.AgentID.String())
}
if snapshot.Status.AgentStatus != "online" {
t.Fatalf("agent_status = %q, want online", snapshot.Status.AgentStatus)
}
if snapshot.Status.UpdateCount != 7 {
t.Fatalf("update_count = %d, want 7", snapshot.Status.UpdateCount)
}
}

View file

@ -0,0 +1,21 @@
//go:build windows
package localapi
import (
"context"
"net"
winio "github.com/Microsoft/go-winio"
)
func platformDialLocalContext(opts ClientOptions) func(context.Context, string, string) (net.Conn, error) {
pipeName := opts.WindowsPipeName
if pipeName == "" {
pipeName = DefaultWindowsPipeName
}
return func(ctx context.Context, _, _ string) (net.Conn, error) {
return winio.DialPipeContext(ctx, pipeName)
}
}

View file

@ -0,0 +1,109 @@
//go:build !windows
package localapi
import (
"fmt"
"net"
"os"
"os/user"
"path/filepath"
"strconv"
"github.com/Fimeg/RedFlag/agent/internal/constants"
)
const unixSocketFile = "redflag-agent.sock"
func defaultGroupName() string {
return DefaultUnixGroupName
}
func defaultUnixSocketPath() string {
return filepath.Join(constants.GetBaseDir(), constants.AgentDir, "localapi", unixSocketFile)
}
func listen(opts Options) (net.Listener, string, error) {
path := unixSocketPath(opts)
group := groupName(opts)
gid, err := lookupGroupID(group)
if err != nil {
return nil, "", formatGroupMissing(group, err)
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, "", fmt.Errorf("localapi: create socket directory %q: %w", dir, err)
}
if err := os.Chown(dir, -1, gid); err != nil {
return nil, "", fmt.Errorf("localapi: chown socket directory %q to group %q: %w", dir, group, err)
}
if err := os.Chmod(dir, 0o750); err != nil {
return nil, "", fmt.Errorf("localapi: chmod socket directory %q: %w", dir, err)
}
if err := removeStaleSocket(path); err != nil {
return nil, "", err
}
ln, err := net.Listen("unix", path)
if err != nil {
return nil, "", fmt.Errorf("localapi: listen on unix socket %q: %w", path, err)
}
if err := os.Chown(path, -1, gid); err != nil {
_ = ln.Close()
_ = os.Remove(path)
return nil, "", fmt.Errorf("localapi: chown socket %q to group %q: %w", path, group, err)
}
if err := os.Chmod(path, 0o660); err != nil {
_ = ln.Close()
_ = os.Remove(path)
return nil, "", fmt.Errorf("localapi: chmod socket %q: %w", path, err)
}
return &cleanupListener{Listener: ln, path: path}, path, nil
}
func lookupGroupID(groupName string) (int, error) {
group, err := user.LookupGroup(groupName)
if err != nil {
return 0, err
}
gid, err := strconv.Atoi(group.Gid)
if err != nil {
return 0, fmt.Errorf("parse gid %q: %w", group.Gid, err)
}
return gid, nil
}
func removeStaleSocket(path string) error {
info, err := os.Lstat(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("localapi: stat socket path %q: %w", path, err)
}
if info.Mode()&os.ModeSocket == 0 {
return fmt.Errorf("localapi: refusing to replace non-socket path %q", path)
}
if err := os.Remove(path); err != nil {
return fmt.Errorf("localapi: remove stale socket %q: %w", path, err)
}
return nil
}
type cleanupListener struct {
net.Listener
path string
}
func (l *cleanupListener) Close() error {
err := l.Listener.Close()
if removeErr := os.Remove(l.path); err == nil && removeErr != nil && !os.IsNotExist(removeErr) {
err = removeErr
}
return err
}

View file

@ -0,0 +1,54 @@
//go:build windows
package localapi
import (
"fmt"
"net"
winio "github.com/Microsoft/go-winio"
"golang.org/x/sys/windows"
)
func defaultGroupName() string {
return DefaultWindowsGroupName
}
func defaultUnixSocketPath() string {
return ""
}
func listen(opts Options) (net.Listener, string, error) {
pipeName := windowsPipeName(opts)
group := groupName(opts)
groupSID, err := lookupGroupSID(group)
if err != nil {
return nil, "", formatGroupMissing(group, err)
}
cfg := &winio.PipeConfig{
// LocalSystem and Administrators get full access; the RedFlag local UI
// group gets read/write transport rights so it can issue HTTP GETs and
// receive responses over the pipe.
SecurityDescriptor: fmt.Sprintf("D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;%s)", groupSID),
}
ln, err := winio.ListenPipe(pipeName, cfg)
if err != nil {
return nil, "", fmt.Errorf("localapi: listen on named pipe %q: %w", pipeName, err)
}
return ln, pipeName, nil
}
func lookupGroupSID(groupName string) (string, error) {
sid, _, accountType, err := windows.LookupSID("", groupName)
if err != nil {
return "", err
}
switch accountType {
case windows.SidTypeGroup, windows.SidTypeAlias, windows.SidTypeWellKnownGroup:
return sid.String(), nil
default:
return "", fmt.Errorf("account %q has unexpected SID type %d", groupName, accountType)
}
}

View file

@ -0,0 +1,288 @@
package localapi
import (
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"sort"
"time"
"github.com/Fimeg/RedFlag/agent/internal/cache"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/version"
)
const (
DefaultUnixGroupName = "redflag-local"
DefaultWindowsGroupName = "RedFlagLocal"
DefaultWindowsPipeName = `\\.\pipe\RedFlagAgentLocal`
)
// Options configures the local read-only API. Group, socket, and pipe defaults
// are platform-specific and enforced by the listener implementation.
type Options struct {
Config *config.Config
GroupName string
UnixSocketPath string
WindowsPipeName string
LoadCache func() (*cache.LocalCache, error)
RequestLog func(format string, args ...interface{})
ListenerOverride net.Listener
}
// Server owns the local API listener and HTTP server.
type Server struct {
httpServer *http.Server
listener net.Listener
address string
logf func(format string, args ...interface{})
}
// Start creates a platform-native local listener and serves the read-only local
// API. It returns an error before serving if the OS-local ACL cannot be applied.
func Start(opts Options) (*Server, error) {
if opts.Config == nil {
return nil, errors.New("localapi: config is required")
}
if opts.LoadCache == nil {
opts.LoadCache = cache.Load
}
if opts.RequestLog == nil {
opts.RequestLog = log.Printf
}
handler := newHandler(opts.Config, opts.LoadCache)
httpServer := &http.Server{Handler: handler}
listener := opts.ListenerOverride
address := ""
var err error
if listener == nil {
listener, address, err = listen(opts)
if err != nil {
return nil, err
}
} else {
address = listener.Addr().String()
}
srv := &Server{
httpServer: httpServer,
listener: listener,
address: address,
logf: opts.RequestLog,
}
go func() {
if err := httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
opts.RequestLog("[ERROR] [agent] [localapi] serve_failed address=%s error=%v", address, err)
}
}()
opts.RequestLog("[INFO] [agent] [localapi] started address=%s", address)
return srv, nil
}
// Stop shuts down the local API listener.
func (s *Server) Stop() {
if s == nil || s.httpServer == nil {
return
}
if err := s.httpServer.Close(); err != nil && s.logf != nil {
s.logf("[WARNING] [agent] [localapi] stop_failed address=%s error=%v", s.address, err)
}
}
type handler struct {
cfg *config.Config
loadCache func() (*cache.LocalCache, error)
}
type IdentityResponse struct {
AgentID string `json:"agent_id"`
ServerURL string `json:"server_url"`
Hostname string `json:"hostname,omitempty"`
OSType string `json:"os_type,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Organization string `json:"organization,omitempty"`
Tags []string `json:"tags,omitempty"`
AgentVersion string `json:"agent_version"`
ConfigVersion string `json:"config_version,omitempty"`
CheckInInterval int `json:"check_in_interval"`
Registered bool `json:"registered"`
}
type StatusResponse struct {
AgentStatus string `json:"agent_status"`
LastCheckIn time.Time `json:"last_check_in,omitempty"`
LastUpdated time.Time `json:"last_updated,omitempty"`
LastScan time.Time `json:"last_scan_time,omitempty"`
UpdateCount int `json:"update_count"`
Summary cache.UpdateSummary `json:"summary"`
Scanners map[string]cache.ScannerState `json:"scanners,omitempty"`
Registered bool `json:"registered"`
}
type ScanResponse struct {
LastScanTime time.Time `json:"last_scan_time,omitempty"`
LastUpdated time.Time `json:"last_updated,omitempty"`
UpdateCount int `json:"update_count"`
Summary cache.UpdateSummary `json:"summary"`
Scanners map[string]cache.ScannerState `json:"scanners,omitempty"`
Updates []client.UpdateReportItem `json:"updates"`
}
type TokenResponse struct {
Capabilities cache.CapabilityTokenState `json:"capabilities"`
}
func newHandler(cfg *config.Config, loadCache func() (*cache.LocalCache, error)) http.Handler {
h := &handler{cfg: cfg, loadCache: loadCache}
mux := http.NewServeMux()
mux.HandleFunc("/v1/identity", h.identity)
mux.HandleFunc("/v1/status", h.status)
mux.HandleFunc("/v1/scans/latest", h.scansLatest)
mux.HandleFunc("/v1/packages", h.scansLatest)
mux.HandleFunc("/v1/tokens/active", h.tokensActive)
return mux
}
func (h *handler) identity(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
hostname, err := os.Hostname()
if err != nil {
log.Printf("[WARNING] [agent] [localapi] hostname_failed error=%v", err)
}
resp := IdentityResponse{
AgentID: h.cfg.AgentID.String(),
ServerURL: h.cfg.ServerURL,
Hostname: hostname,
OSType: h.cfg.OSType,
DisplayName: h.cfg.DisplayName,
Organization: h.cfg.Organization,
Tags: sortedCopy(h.cfg.Tags),
AgentVersion: version.Version,
ConfigVersion: h.cfg.Version,
CheckInInterval: h.cfg.CheckInInterval,
Registered: h.cfg.IsRegistered(),
}
writeJSON(w, resp)
}
func (h *handler) status(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
localCache, ok := h.load(w)
if !ok {
return
}
writeJSON(w, StatusResponse{
AgentStatus: localCache.AgentStatus,
LastCheckIn: localCache.LastCheckIn,
LastUpdated: localCache.LastUpdated,
LastScan: localCache.LastScanTime,
UpdateCount: localCache.UpdateCount,
Summary: localCache.Summary,
Scanners: localCache.Scanners,
Registered: h.cfg.IsRegistered(),
})
}
func (h *handler) scansLatest(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
localCache, ok := h.load(w)
if !ok {
return
}
writeJSON(w, ScanResponse{
LastScanTime: localCache.LastScanTime,
LastUpdated: localCache.LastUpdated,
UpdateCount: localCache.UpdateCount,
Summary: localCache.Summary,
Scanners: localCache.Scanners,
Updates: localCache.Updates,
})
}
func (h *handler) tokensActive(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
localCache, ok := h.load(w)
if !ok {
return
}
writeJSON(w, TokenResponse{Capabilities: localCache.Capabilities})
}
func (h *handler) load(w http.ResponseWriter) (*cache.LocalCache, bool) {
localCache, err := h.loadCache()
if err != nil {
log.Printf("[ERROR] [agent] [localapi] cache_load_failed error=%v", err)
http.Error(w, "local state unavailable", http.StatusServiceUnavailable)
return nil, false
}
return localCache, true
}
func requireGet(w http.ResponseWriter, r *http.Request) bool {
if r.Method == http.MethodGet {
return true
}
w.Header().Set("Allow", http.MethodGet)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return false
}
func writeJSON(w http.ResponseWriter, value interface{}) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(value); err != nil {
log.Printf("[WARNING] [agent] [localapi] response_encode_failed error=%v", err)
}
}
func sortedCopy(values []string) []string {
if len(values) == 0 {
return nil
}
copied := append([]string(nil), values...)
sort.Strings(copied)
return copied
}
func groupName(opts Options) string {
if opts.GroupName != "" {
return opts.GroupName
}
return defaultGroupName()
}
func unixSocketPath(opts Options) string {
if opts.UnixSocketPath != "" {
return opts.UnixSocketPath
}
return defaultUnixSocketPath()
}
func windowsPipeName(opts Options) string {
if opts.WindowsPipeName != "" {
return opts.WindowsPipeName
}
return DefaultWindowsPipeName
}
func formatGroupMissing(name string, err error) error {
return fmt.Errorf("localapi: local access group %q unavailable: %w", name, err)
}

View file

@ -0,0 +1,184 @@
package localapi
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Fimeg/RedFlag/agent/internal/cache"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/gofrs/uuid/v5"
)
func TestIdentityRedactsSecrets(t *testing.T) {
cfg := testConfig(t)
handler := newHandler(cfg, func() (*cache.LocalCache, error) {
return &cache.LocalCache{}, nil
})
req := httptest.NewRequest(http.MethodGet, "/v1/identity", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
body := rec.Body.String()
for _, forbidden := range []string{"secret-access-token", "secret-refresh-token", "registration-token", "refresh_token", "token"} {
if strings.Contains(body, forbidden) {
t.Fatalf("identity response leaked %q: %s", forbidden, body)
}
}
var resp IdentityResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.AgentID != cfg.AgentID.String() {
t.Fatalf("agent_id = %q, want %q", resp.AgentID, cfg.AgentID.String())
}
if resp.Registered != true {
t.Fatalf("registered = false, want true")
}
if got := strings.Join(resp.Tags, ","); got != "alpha,zeta" {
t.Fatalf("tags = %q, want sorted alpha,zeta", got)
}
}
func TestStatusAndPackagesReadLocalCache(t *testing.T) {
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
localCache := &cache.LocalCache{
LastScanTime: now.Add(-2 * time.Minute),
LastCheckIn: now.Add(-1 * time.Minute),
LastUpdated: now,
UpdateCount: 1,
AgentStatus: "online",
Summary: cache.UpdateSummary{
Total: 1,
ByEcosystem: map[string]int{"windows": 1},
BySeverity: map[string]int{"critical": 1},
},
Scanners: map[string]cache.ScannerState{
"windows": {
Name: "windows",
Status: "success",
UpdateCount: 1,
},
},
Updates: []client.UpdateReportItem{
{
PackageType: "windows",
PackageName: "KB5000001",
CurrentVersion: "1",
AvailableVersion: "2",
Severity: "critical",
},
},
Capabilities: cache.CapabilityTokenState{
PendingCount: 2,
LastFetchedCount: 3,
LastProcessedCount: 1,
},
}
handler := newHandler(testConfig(t), func() (*cache.LocalCache, error) {
return localCache, nil
})
var status StatusResponse
requestJSON(t, handler, http.MethodGet, "/v1/status", &status)
if status.AgentStatus != "online" {
t.Fatalf("agent_status = %q, want online", status.AgentStatus)
}
if status.Summary.Total != 1 {
t.Fatalf("summary.total = %d, want 1", status.Summary.Total)
}
var packages ScanResponse
requestJSON(t, handler, http.MethodGet, "/v1/packages", &packages)
if len(packages.Updates) != 1 {
t.Fatalf("updates len = %d, want 1", len(packages.Updates))
}
if packages.Updates[0].PackageName != "KB5000001" {
t.Fatalf("package name = %q, want KB5000001", packages.Updates[0].PackageName)
}
var tokens TokenResponse
requestJSON(t, handler, http.MethodGet, "/v1/tokens/active", &tokens)
if tokens.Capabilities.PendingCount != 2 {
t.Fatalf("pending tokens = %d, want 2", tokens.Capabilities.PendingCount)
}
}
func TestCacheLoadFailureReturnsServiceUnavailable(t *testing.T) {
handler := newHandler(testConfig(t), func() (*cache.LocalCache, error) {
return nil, errors.New("cannot read cache")
})
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
}
}
func TestOnlyGetMethodsAllowed(t *testing.T) {
handler := newHandler(testConfig(t), func() (*cache.LocalCache, error) {
return &cache.LocalCache{}, nil
})
req := httptest.NewRequest(http.MethodPost, "/v1/status", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
}
if rec.Header().Get("Allow") != http.MethodGet {
t.Fatalf("Allow = %q, want GET", rec.Header().Get("Allow"))
}
}
func requestJSON(t *testing.T, handler http.Handler, method, path string, target interface{}) {
t.Helper()
req := httptest.NewRequest(method, path, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s %s status = %d, want %d; body=%s", method, path, rec.Code, http.StatusOK, rec.Body.String())
}
if err := json.Unmarshal(rec.Body.Bytes(), target); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
}
func testConfig(t *testing.T) *config.Config {
t.Helper()
agentID, err := uuid.NewV4()
if err != nil {
t.Fatalf("new uuid: %v", err)
}
return &config.Config{
Version: "5",
ServerURL: "https://redflag.example",
RegistrationToken: "registration-token",
AgentID: agentID,
Token: "secret-access-token",
RefreshToken: "secret-refresh-token",
CheckInInterval: 300,
Tags: []string{"zeta", "alpha"},
DisplayName: "workstation-01",
Organization: "lab",
OSType: "windows",
}
}

17
desktop/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "redflag-desktop"
version = "0.1.0"
edition = "2021"
publish = false
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = ["tray-icon"] }
[[bin]]
name = "redflag-desktop"
path = "src/main.rs"

13
desktop/README.md Normal file
View file

@ -0,0 +1,13 @@
# RedFlag Desktop
Tauri shell for the same RedFlag app in local-agent context.
The desktop shell does not read protected config or state files. Its Rust backend talks to
the existing agent local IPC surface:
- Linux: `/var/lib/redflag/agent/localapi/redflag-agent.sock`
- Windows: `\\.\pipe\RedFlagAgentLocal`
The frontend entry is `web/index.desktop.html` and `web/src/desktop/main.tsx`.
Fleet-server context should be added to this same app later rather than creating a second
local-only application.

3
desktop/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

6
desktop/package-lock.json generated Normal file
View file

@ -0,0 +1,6 @@
{
"name": "desktop",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

167
desktop/src/main.rs Normal file
View file

@ -0,0 +1,167 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::{Read, Write};
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager,
};
#[cfg(unix)]
const LOCAL_SOCKET_PATH: &str = "/var/lib/redflag/agent/localapi/redflag-agent.sock";
#[cfg(windows)]
const LOCAL_PIPE_NAME: &str = r"\\.\pipe\RedFlagAgentLocal";
#[derive(Debug, Serialize, Deserialize)]
struct LocalSnapshot {
identity: IdentityResponse,
status: StatusResponse,
}
#[derive(Debug, Serialize, Deserialize)]
struct IdentityResponse {
agent_id: String,
server_url: String,
hostname: Option<String>,
os_type: Option<String>,
display_name: Option<String>,
organization: Option<String>,
tags: Option<Vec<String>>,
agent_version: String,
config_version: Option<String>,
check_in_interval: i64,
registered: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct StatusResponse {
agent_status: String,
last_check_in: Option<String>,
last_updated: Option<String>,
last_scan_time: Option<String>,
update_count: i64,
summary: UpdateSummary,
scanners: Option<BTreeMap<String, ScannerState>>,
registered: bool,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct UpdateSummary {
total: i64,
by_ecosystem: Option<BTreeMap<String, i64>>,
by_severity: Option<BTreeMap<String, i64>>,
}
#[derive(Debug, Serialize, Deserialize)]
struct ScannerState {
name: String,
status: String,
last_scan_time: Option<String>,
last_duration_ms: Option<i64>,
last_error: Option<String>,
update_count: i64,
}
trait ReadWrite: Read + Write {}
impl<T: Read + Write> ReadWrite for T {}
#[tauri::command]
fn local_status() -> Result<LocalSnapshot, String> {
let identity: IdentityResponse = local_get_json("/v1/identity")?;
let status: StatusResponse = local_get_json("/v1/status")?;
Ok(LocalSnapshot { identity, status })
}
fn local_get_json<T: for<'de> Deserialize<'de>>(path: &str) -> Result<T, String> {
let body = local_get(path)?;
serde_json::from_str(&body).map_err(|err| format!("decode local API response: {err}"))
}
fn local_get(path: &str) -> Result<String, String> {
let mut stream = connect_local_api()?;
let request = format!(
"GET {path} HTTP/1.1\r\nHost: redflag.local\r\nAccept: application/json\r\nConnection: close\r\n\r\n"
);
stream
.write_all(request.as_bytes())
.map_err(|err| format!("write local API request: {err}"))?;
stream
.flush()
.map_err(|err| format!("flush local API request: {err}"))?;
let mut response = String::new();
stream
.read_to_string(&mut response)
.map_err(|err| format!("read local API response: {err}"))?;
let (head, body) = response
.split_once("\r\n\r\n")
.ok_or_else(|| "local API response missing HTTP headers".to_string())?;
let status = head
.lines()
.next()
.ok_or_else(|| "local API response missing status line".to_string())?;
if !status.contains(" 200 ") {
return Err(format!("local API returned {status}"));
}
Ok(body.to_string())
}
#[cfg(unix)]
fn connect_local_api() -> Result<Box<dyn ReadWrite>, String> {
let stream = std::os::unix::net::UnixStream::connect(LOCAL_SOCKET_PATH)
.map_err(|err| format!("connect local API socket {LOCAL_SOCKET_PATH}: {err}"))?;
Ok(Box::new(stream))
}
#[cfg(windows)]
fn connect_local_api() -> Result<Box<dyn ReadWrite>, String> {
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(LOCAL_PIPE_NAME)
.map_err(|err| format!("connect local API pipe {LOCAL_PIPE_NAME}: {err}"))?;
Ok(Box::new(file))
}
fn show_main_window(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![local_status])
.setup(|app| {
let show_i = MenuItem::with_id(app, "show", "Show RedFlag", true, None::<&str>)?;
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_i, &quit_i])?;
let _tray = TrayIconBuilder::new()
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => show_main_window(app),
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main_window(tray.app_handle());
}
})
.build(app)?;
Ok(())
})
.run(tauri::generate_context!())
.expect("error running RedFlag desktop");
}

32
desktop/tauri.conf.json Normal file
View file

@ -0,0 +1,32 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "RedFlag",
"version": "0.1.0",
"identifier": "com.redflag.local",
"build": {
"beforeDevCommand": "npm --prefix ../web run dev:desktop",
"devUrl": "http://127.0.0.1:3001",
"beforeBuildCommand": "npm --prefix ../web run build:desktop",
"frontendDist": "../web/dist-desktop"
},
"app": {
"windows": [
{
"label": "main",
"title": "RedFlag Local Agent",
"width": 720,
"height": 760,
"minWidth": 420,
"minHeight": 560,
"resizable": true,
"visible": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": false
}
}

View file

@ -709,6 +709,7 @@ func main() {
dashboard.POST("/agents/:id/heartbeat", agentHandler.TriggerHeartbeat)
dashboard.GET("/agents/:id/heartbeat", agentHandler.GetHeartbeatStatus)
dashboard.POST("/agents/:id/reboot", agentHandler.TriggerReboot)
dashboard.POST("/agents/:id/screenshot", agentHandler.TriggerCaptureScreenshot)
// BUG-013: admin-initiated agent removal lives in the web-auth group
// (not the agent-auth group above), since the caller is the dashboard
// admin, not the agent itself.
@ -771,6 +772,7 @@ func main() {
// Command routes
dashboard.GET("/commands/active", updateHandler.GetActiveCommands)
dashboard.GET("/commands/recent", updateHandler.GetRecentCommands)
dashboard.GET("/commands/:id", updateHandler.GetCommandByID)
dashboard.POST("/commands/:id/retry", updateHandler.RetryCommand)
dashboard.POST("/commands/:id/cancel", updateHandler.CancelCommand)
dashboard.DELETE("/commands/failed", updateHandler.ClearFailedCommands)

View file

@ -1782,6 +1782,47 @@ func (h *AgentHandler) TriggerReboot(c *gin.Context) {
})
}
// TriggerCaptureScreenshot sends a capture_screenshot command to an agent.
// The agent captures its display, base64-encodes the PNG, and reports it back
// in the command result's stdout field. The UI polls GET /commands/:id to
// retrieve the image once the command completes.
func (h *AgentHandler) TriggerCaptureScreenshot(c *gin.Context) {
agentID, err := uuid.FromString(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
// Verify agent exists
if _, err := h.agentQueries.GetAgentByID(agentID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
cmd := &models.AgentCommand{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentID,
CommandType: models.CommandTypeCaptureScreenshot,
Params: models.JSONB{},
Status: models.CommandStatusPending,
Source: models.CommandSourceManual,
CreatedAt: time.Now().UTC(),
}
if err := h.signAndCreateCommand(cmd); err != nil {
log.Printf("[ERROR] [server] [screenshot] command_create_failed agent_id=%s error=%v", agentID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create screenshot command"})
return
}
log.Printf("[INFO] [server] [screenshot] command_created agent_id=%s command_id=%s", agentID, cmd.ID)
c.JSON(http.StatusOK, gin.H{
"message": "screenshot command sent",
"command_id": cmd.ID,
})
}
// GetAgentConfig returns current subsystem configuration for an agent
// GET /api/v1/agents/:id/config
func (h *AgentHandler) GetAgentConfig(c *gin.Context) {

View file

@ -2716,6 +2716,24 @@ func (h *UpdateHandler) GetActiveCommands(c *gin.Context) {
})
}
// GetCommandByID retrieves a single command by ID. Used by the UI to poll
// for the result of a capture_screenshot command.
func (h *UpdateHandler) GetCommandByID(c *gin.Context) {
commandID, err := uuid.FromString(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid command ID"})
return
}
command, err := h.commandQueries.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "command not found"})
return
}
c.JSON(http.StatusOK, command)
}
// GetRecentCommands retrieves recent commands for retry functionality
func (h *UpdateHandler) GetRecentCommands(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))

View file

@ -108,6 +108,7 @@ const (
CommandTypeEnableHeartbeat = "enable_heartbeat"
CommandTypeDisableHeartbeat = "disable_heartbeat"
CommandTypeReboot = "reboot"
CommandTypeCaptureScreenshot = "capture_screenshot"
)
// Command statuses

View file

@ -16,6 +16,7 @@ fi
# Variables
AGENT_ID="{{.AgentID}}"
AGENT_USER="redflag-agent"
LOCAL_API_GROUP="redflag-local"
AGENT_HOME="{{.AgentHome}}"
BASE_DIR="/var/lib/redflag"
CONFIG_DIR="/etc/redflag"
@ -116,6 +117,21 @@ else
echo "✓ User $AGENT_USER created"
fi
echo "Creating local API access group..."
if getent group "$LOCAL_API_GROUP" >/dev/null 2>&1; then
echo "✓ Group $LOCAL_API_GROUP already exists"
else
sudo groupadd --system "$LOCAL_API_GROUP"
echo "✓ Group $LOCAL_API_GROUP created"
fi
if id -nG "$AGENT_USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$LOCAL_API_GROUP"; then
echo "✓ $AGENT_USER already in $LOCAL_API_GROUP group"
else
sudo usermod -aG "$LOCAL_API_GROUP" "$AGENT_USER"
echo "✓ Added $AGENT_USER to $LOCAL_API_GROUP group (local API socket ownership)"
fi
# Grant docker socket access so the container scanner can reach the daemon.
# Sudoers entries below only cover pull/inspect; IsAvailable() pings the
# unix socket, which is root:docker. Membership is the standard pattern.
@ -137,16 +153,21 @@ if [ ! -d "$AGENT_HOME" ]; then
sudo mkdir -p "$AGENT_HOME"
sudo mkdir -p "$AGENT_HOME/cache"
sudo mkdir -p "$AGENT_HOME/state"
sudo mkdir -p "$AGENT_HOME/localapi"
sudo mkdir -p "$AGENT_CONFIG_DIR"
sudo mkdir -p "$SERVER_KEY_DIR"
sudo mkdir -p "$AGENT_LOG_DIR"
# Set ownership and permissions
sudo chown -R "$AGENT_USER:$AGENT_USER" "$BASE_DIR"
sudo chmod 750 "$BASE_DIR"
sudo chmod 750 "$AGENT_HOME"
sudo chown "$AGENT_USER:$LOCAL_API_GROUP" "$BASE_DIR"
sudo chown "$AGENT_USER:$LOCAL_API_GROUP" "$AGENT_HOME"
sudo chmod 710 "$BASE_DIR"
sudo chmod 710 "$AGENT_HOME"
sudo chmod 750 "$AGENT_HOME/cache"
sudo chmod 750 "$AGENT_HOME/state"
sudo chown "$AGENT_USER:$LOCAL_API_GROUP" "$AGENT_HOME/localapi"
sudo chmod 750 "$AGENT_HOME/localapi"
sudo chmod 755 "$AGENT_CONFIG_DIR"
sudo chown "$AGENT_USER:$AGENT_USER" "$SERVER_KEY_DIR"
sudo chmod 755 "$SERVER_KEY_DIR"
@ -686,6 +707,7 @@ StartLimitIntervalSec=60
Type=simple
User={{.AgentUser}}
Group={{.AgentUser}}
SupplementaryGroups=${LOCAL_API_GROUP}
WorkingDirectory={{.AgentHome}}
ExecStart=${INSTALL_DIR}/${SERVICE_NAME}
Restart=always
@ -727,7 +749,13 @@ sudo chown -R {{.AgentUser}}:{{.AgentUser}} "{{.AgentConfigDir}}"
sudo chown {{.AgentUser}}:{{.AgentUser}} "{{.AgentConfigDir}}/config.json"
sudo chmod 600 "{{.AgentConfigDir}}/config.json"
sudo chown -R {{.AgentUser}}:{{.AgentUser}} "{{.AgentHome}}"
sudo chmod 750 "{{.AgentHome}}"
sudo chown {{.AgentUser}}:${LOCAL_API_GROUP} "${BASE_DIR}"
sudo chown {{.AgentUser}}:${LOCAL_API_GROUP} "{{.AgentHome}}"
sudo chmod 710 "${BASE_DIR}"
sudo chmod 710 "{{.AgentHome}}"
sudo mkdir -p "{{.AgentHome}}/localapi"
sudo chown {{.AgentUser}}:${LOCAL_API_GROUP} "{{.AgentHome}}/localapi"
sudo chmod 750 "{{.AgentHome}}/localapi"
sudo chown -R {{.AgentUser}}:{{.AgentUser}} "{{.AgentLogDir}}"
sudo chmod 750 "{{.AgentLogDir}}"
# Server public key directory - agent needs to write the TOFU cached key here
@ -810,6 +838,7 @@ if systemctl is-active --quiet ${SERVICE_NAME}; then
echo "=== Security Information ==="
echo "Agent is running with security hardening:"
echo " ✓ Dedicated system user: {{.AgentUser}}"
echo " ✓ Local API group: ${LOCAL_API_GROUP}"
echo " ✓ Limited sudo access for package management only"
echo " ✓ Systemd service with security restrictions"
echo " ✓ Protected configuration directory"

View file

@ -128,6 +128,81 @@ function Repair-AgentConfigAccessIfNeeded {
}
}
function Ensure-LocalApiGroup {
param([Parameter(Mandatory=$true)][string]$GroupName)
Write-Host "Ensuring local API access group..." -ForegroundColor Yellow
$LocalGroupCommandsAvailable = $null -ne (Get-Command Get-LocalGroup -ErrorAction SilentlyContinue) -and
$null -ne (Get-Command New-LocalGroup -ErrorAction SilentlyContinue) -and
$null -ne (Get-Command Add-LocalGroupMember -ErrorAction SilentlyContinue)
if ($LocalGroupCommandsAvailable) {
$Group = Get-LocalGroup -Name $GroupName -ErrorAction SilentlyContinue
if (-not $Group) {
New-LocalGroup -Name $GroupName -Description "RedFlag local agent API access" | Out-Null
Write-Host "✓ Group $GroupName created" -ForegroundColor Green
} else {
Write-Host "✓ Group $GroupName already exists" -ForegroundColor Green
}
$CurrentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
if ($CurrentIdentity.User.Value -eq "S-1-5-18") {
Write-Host "[INFO] [installer] [localapi] Running as LocalSystem; no interactive user added to $GroupName" -ForegroundColor Gray
return
}
$CurrentUser = $CurrentIdentity.Name
try {
Add-LocalGroupMember -Group $GroupName -Member $CurrentUser -ErrorAction Stop
Write-Host "✓ Added $CurrentUser to $GroupName" -ForegroundColor Green
Write-Host "[INFO] [installer] [localapi] Sign out and back in if non-elevated local API access is not immediately available" -ForegroundColor Gray
} catch {
if ($_.Exception.Message -match "already.*member|already.*exists") {
Write-Host "✓ $CurrentUser already in $GroupName" -ForegroundColor Green
} else {
throw
}
}
return
}
# Windows PowerShell fallback for hosts without Microsoft.PowerShell.LocalAccounts.
$ComputerName = $env:COMPUTERNAME
try {
$Group = [ADSI]"WinNT://$ComputerName/$GroupName,group"
$null = $Group.Name
Write-Host "✓ Group $GroupName already exists" -ForegroundColor Green
} catch {
$Machine = [ADSI]"WinNT://$ComputerName"
$Group = $Machine.Create("group", $GroupName)
$Group.SetInfo()
$Group.Description = "RedFlag local agent API access"
$Group.SetInfo()
Write-Host "✓ Group $GroupName created" -ForegroundColor Green
}
$CurrentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
if ($CurrentIdentity.User.Value -eq "S-1-5-18") {
Write-Host "[INFO] [installer] [localapi] Running as LocalSystem; no interactive user added to $GroupName" -ForegroundColor Gray
return
}
$CurrentUser = $CurrentIdentity.Name
$MemberPath = "WinNT://" + $CurrentUser.Replace("\", "/")
try {
$Group.Add($MemberPath)
Write-Host "✓ Added $CurrentUser to $GroupName" -ForegroundColor Green
Write-Host "[INFO] [installer] [localapi] Sign out and back in if non-elevated local API access is not immediately available" -ForegroundColor Gray
} catch {
if ($_.Exception.Message -match "already.*member|The object already exists") {
Write-Host "✓ $CurrentUser already in $GroupName" -ForegroundColor Green
} else {
throw
}
}
}
# Runtime admin check for better error messaging
if (-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
Write-Error "This installer must be run as Administrator."
@ -155,6 +230,7 @@ $AgentConfigDir = "C:\ProgramData\RedFlag\agent"
$ServerKeyDir = "C:\ProgramData\RedFlag\server"
$OldConfigDir = "C:\ProgramData\Aggregator"
$ServiceName = "RedFlagAgent"
$LocalApiGroup = "RedFlagLocal"
$Version = "{{.Version}}"
$Timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$BackupDir = Join-Path $ConfigDir "backups\backup.$Timestamp"
@ -273,6 +349,8 @@ New-Item -ItemType Directory -Force -Path "$ConfigDir\backups" | Out-Null
New-Item -ItemType Directory -Force -Path "$ConfigDir\state" | Out-Null
New-Item -ItemType Directory -Force -Path "$ConfigDir\logs" | Out-Null
Ensure-LocalApiGroup -GroupName $LocalApiGroup
# Step 3: Download agent binary
Write-Host "Downloading agent binary..." -ForegroundColor Yellow
$BinaryPath = Join-Path $InstallDir "redflag-agent.exe"

13
web/index.desktop.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RedFlag Local Agent</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/desktop/main.tsx"></script>
</body>
</html>

View file

@ -5,13 +5,16 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:desktop": "vite --config vite.desktop.config.ts --host 127.0.0.1",
"build": "tsc && vite build",
"build:desktop": "tsc && vite build --config vite.desktop.config.ts",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.8.4",
"@tanstack/react-query-devtools": "^5.90.2",
"@tauri-apps/api": "^2.0.0",
"axios": "^1.15.0",
"clsx": "^2.0.0",
"lucide-react": "^0.294.0",
@ -29,6 +32,7 @@
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@tauri-apps/cli": "^2.0.0",
"@vitejs/plugin-react": "6.0.2",
"autoprefixer": "^10.4.16",
"eslint": "^8.53.0",

View file

@ -0,0 +1,253 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import {
Activity,
AlertTriangle,
CheckCircle2,
Clock,
Monitor,
Package,
RefreshCw,
Server,
ShieldCheck,
WifiOff,
} from 'lucide-react'
import { cn, formatRelativeTime } from '@/lib/utils'
interface LocalIdentity {
agent_id: string
server_url: string
hostname?: string
os_type?: string
display_name?: string
organization?: string
tags?: string[]
agent_version: string
config_version?: string
check_in_interval: number
registered: boolean
}
interface UpdateSummary {
total: number
by_ecosystem?: Record<string, number>
by_severity?: Record<string, number>
}
interface ScannerState {
name: string
status: string
last_scan_time?: string
last_duration_ms?: number
last_error?: string
update_count: number
}
interface LocalStatus {
agent_status: string
last_check_in?: string
last_updated?: string
last_scan_time?: string
update_count: number
summary: UpdateSummary
scanners?: Record<string, ScannerState>
registered: boolean
}
interface LocalSnapshot {
identity: LocalIdentity
status: LocalStatus
}
type HealthState = 'healthy' | 'warning' | 'error'
const LocalAgentApp: React.FC = () => {
const [snapshot, setSnapshot] = useState<LocalSnapshot | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [lastRefresh, setLastRefresh] = useState<Date | null>(null)
const load = useCallback(async () => {
try {
setError(null)
const next = await invoke<LocalSnapshot>('local_status')
setSnapshot(next)
setLastRefresh(new Date())
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
const id = window.setInterval(load, 5000)
return () => window.clearInterval(id)
}, [load])
const health = useMemo<HealthState>(() => {
if (error || !snapshot) return 'error'
if (!snapshot.identity.registered || !snapshot.status.registered) return 'warning'
if (snapshot.status.agent_status && snapshot.status.agent_status !== 'online') return 'warning'
const critical = snapshot.status.summary.by_severity?.critical ?? 0
return critical > 0 ? 'warning' : 'healthy'
}, [error, snapshot])
const scanners = useMemo(() => {
if (!snapshot?.status.scanners) return []
return Object.values(snapshot.status.scanners).sort((a, b) => a.name.localeCompare(b.name))
}, [snapshot])
return (
<main className="min-h-screen bg-gray-50 text-gray-900">
<header className="border-b border-gray-200 bg-white">
<div className="flex items-center justify-between px-5 py-4">
<div className="flex items-center gap-3">
<div className={cn(
'flex h-9 w-9 items-center justify-center rounded-md',
health === 'healthy' && 'bg-success-50 text-success-700',
health === 'warning' && 'bg-warning-50 text-warning-700',
health === 'error' && 'bg-danger-50 text-danger-700',
)}>
{health === 'healthy' ? <ShieldCheck className="h-5 w-5" /> : health === 'warning' ? <AlertTriangle className="h-5 w-5" /> : <WifiOff className="h-5 w-5" />}
</div>
<div>
<h1 className="text-base font-semibold leading-tight">RedFlag Local Agent</h1>
<p className="text-xs text-gray-500">{snapshot?.identity.hostname || 'Local machine'}</p>
</div>
</div>
<button
type="button"
onClick={load}
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-gray-200 text-gray-600 hover:bg-gray-50"
title="Refresh"
>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
</button>
</div>
</header>
<section className="px-5 py-4">
{error && (
<div className="mb-4 rounded-md border border-danger-200 bg-danger-50 px-3 py-2 text-sm text-danger-800">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<Metric
icon={Server}
label="Fleet"
value={snapshot?.identity.registered ? 'Bound' : 'Unbound'}
tone={snapshot?.identity.registered ? 'success' : 'warning'}
/>
<Metric
icon={Package}
label="Updates"
value={String(snapshot?.status.update_count ?? 0)}
tone={(snapshot?.status.update_count ?? 0) > 0 ? 'warning' : 'success'}
/>
<Metric
icon={Clock}
label="Check-in"
value={formatMaybeRelative(snapshot?.status.last_check_in)}
/>
<Metric
icon={Activity}
label="Scan"
value={formatMaybeRelative(snapshot?.status.last_scan_time)}
/>
</div>
<div className="mt-4 rounded-md border border-gray-200 bg-white">
<div className="border-b border-gray-200 px-4 py-3">
<h2 className="text-sm font-medium text-gray-900">Identity</h2>
</div>
<dl className="grid grid-cols-[112px_1fr] gap-x-3 gap-y-2 px-4 py-3 text-sm">
<dt className="text-gray-500">Agent</dt>
<dd className="truncate font-mono text-xs text-gray-900">{snapshot?.identity.agent_id || '-'}</dd>
<dt className="text-gray-500">Server</dt>
<dd className="truncate text-gray-900">{snapshot?.identity.server_url || '-'}</dd>
<dt className="text-gray-500">Version</dt>
<dd className="text-gray-900">{snapshot?.identity.agent_version || '-'}</dd>
<dt className="text-gray-500">Platform</dt>
<dd className="text-gray-900">{snapshot?.identity.os_type || '-'}</dd>
</dl>
</div>
<div className="mt-4 rounded-md border border-gray-200 bg-white">
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
<h2 className="text-sm font-medium text-gray-900">Scanners</h2>
<span className="text-xs text-gray-500">{scanners.length}</span>
</div>
<div className="divide-y divide-gray-100">
{scanners.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500">No scanner state reported</div>
) : (
scanners.map((scanner) => (
<div key={scanner.name} className="flex items-center gap-3 px-4 py-3">
<ScannerIcon status={scanner.status} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium text-gray-900">{scanner.name}</p>
<span className="text-xs text-gray-500">{scanner.update_count} updates</span>
</div>
<p className={cn('truncate text-xs', scanner.last_error ? 'text-danger-700' : 'text-gray-500')}>
{scanner.last_error || formatMaybeRelative(scanner.last_scan_time)}
</p>
</div>
</div>
))
)}
</div>
</div>
<footer className="mt-4 flex items-center justify-between text-xs text-gray-500">
<span>{lastRefresh ? `Updated ${formatRelativeTime(lastRefresh.toISOString())}` : 'Awaiting status'}</span>
<span>{snapshot?.status.agent_status || 'unknown'}</span>
</footer>
</section>
</main>
)
}
interface MetricProps {
icon: React.ComponentType<{ className?: string }>
label: string
value: string
tone?: 'success' | 'warning' | 'neutral'
}
const Metric: React.FC<MetricProps> = ({ icon: Icon, label, value, tone = 'neutral' }) => (
<div className="rounded-md border border-gray-200 bg-white p-3">
<div className="mb-2 flex items-center justify-between">
<Icon className="h-4 w-4 text-gray-500" />
<span className={cn(
'h-2 w-2 rounded-full',
tone === 'success' && 'bg-success-500',
tone === 'warning' && 'bg-warning-500',
tone === 'neutral' && 'bg-gray-300',
)} />
</div>
<p className="text-xs text-gray-500">{label}</p>
<p className="mt-1 truncate text-sm font-semibold text-gray-900">{value}</p>
</div>
)
const ScannerIcon: React.FC<{ status: string }> = ({ status }) => {
if (status === 'success') {
return <CheckCircle2 className="h-4 w-4 flex-shrink-0 text-success-600" />
}
if (status === 'failed') {
return <AlertTriangle className="h-4 w-4 flex-shrink-0 text-danger-600" />
}
return <Monitor className="h-4 w-4 flex-shrink-0 text-gray-400" />
}
function formatMaybeRelative(value?: string): string {
if (!value || value.startsWith('0001-')) return '-'
return formatRelativeTime(value)
}
export default LocalAgentApp

10
web/src/desktop/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import LocalAgentApp from './LocalAgentApp'
import '../index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<LocalAgentApp />
</React.StrictMode>,
)

View file

@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { updateApi } from '@/lib/api';
import { updateApi, agentApi } from '@/lib/api';
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
interface ActiveCommand {
@ -72,4 +72,35 @@ export const useClearFailedCommands = (): UseMutationResult<{ message: string; c
queryClient.invalidateQueries({ queryKey: ['recentCommands'] });
},
});
};
// Trigger a screenshot capture on an agent. Returns the command ID.
export const useCaptureScreenshot = (): UseMutationResult<{ message: string; command_id: string }, Error, string, unknown> => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (agentId: string) => agentApi.captureScreenshot(agentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activeCommands'] });
},
});
};
// Poll a specific command until it completes. Used to retrieve the
// screenshot image from the command result's stdout field.
export const useCommand = (commandId: string | null, enabled: boolean = true): UseQueryResult<any, Error> => {
return useQuery({
queryKey: ['command', commandId],
queryFn: () => agentApi.getCommand(commandId!),
enabled: !!commandId && enabled,
refetchInterval: (query: any) => {
// Stop polling once the command reaches a terminal state
const status = query.state.data?.status;
if (status === 'completed' || status === 'failed' || status === 'timed_out' || status === 'cancelled') {
return false;
}
return 2000; // Poll every 2 seconds while in flight
},
staleTime: 0,
});
};

View file

@ -154,6 +154,18 @@ export const agentApi = {
return response.data;
},
// Trigger screenshot capture on an agent
captureScreenshot: async (agentId: string): Promise<{ message: string; command_id: string }> => {
const response = await api.post(`/agents/${agentId}/screenshot`);
return response.data;
},
// Get a single command by ID (used to poll screenshot result)
getCommand: async (commandId: string): Promise<any> => {
const response = await api.get(`/commands/${commandId}`);
return response.data;
},
// Trigger agent reboot
rebootAgent: async (id: string, delayMinutes: number = 1, message?: string): Promise<void> => {
await api.post(`/agents/${id}/reboot`, {

View file

@ -26,7 +26,7 @@ import {
import { SearchInput, FilterDropdown } from '@/components/primitives';
import { useDebounce } from '@/hooks/useDebounce';
import { useAgents, useAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
import { useActiveCommands, useCancelCommand } from '@/hooks/useCommands';
import { useActiveCommands, useCancelCommand, useCaptureScreenshot, useCommand } from '@/hooks/useCommands';
import { useHeartbeatStatus } from '@/hooks/useHeartbeat';
import { agentApi } from '@/lib/api';
import { useQueryClient } from '@tanstack/react-query';
@ -40,7 +40,7 @@ import { AgentUpdatesModal } from '@/components/AgentUpdatesModal';
import { BulkAgentUpdate } from '@/components/RelayList';
import ChatTimeline from '@/components/ChatTimeline';
import AgentSoftwareBindings from '@/components/AgentSoftwareBindings';
import { AgentIntegrations } from '@/components/AgentIntegrations';
import { readIntegrations, resolveState } from '@/types/integrations';
type AgentDetailTab = 'overview' | 'storage' | 'updates' | 'software' | 'scanners' | 'history';
@ -66,6 +66,7 @@ const Agents: React.FC = () => {
const [heartbeatLoading, setHeartbeatLoading] = useState(false); // Loading state for heartbeat toggle
const [showUpdateModal, setShowUpdateModal] = useState(false); // Update modal state
const [singleAgentUpdate, setSingleAgentUpdate] = useState<string | null>(null); // Single agent update modal
const [screenshotCommandId, setScreenshotCommandId] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
@ -228,7 +229,10 @@ const Agents: React.FC = () => {
const { data: activeCommandsData, refetch: refetchActiveCommands } = useActiveCommands();
const cancelCommandMutation = useCancelCommand();
// Screenshot capture
const captureScreenshotMutation = useCaptureScreenshot();
const { data: screenshotCommand } = useCommand(screenshotCommandId, !!screenshotCommandId);
const agents = agentsData?.agents || [];
const selectedAgent = selectedAgentData || agents.find(a => a.id === id);
@ -323,6 +327,22 @@ const Agents: React.FC = () => {
}
};
// Handle screenshot capture
const handleCaptureScreenshot = async (agentId: string) => {
try {
const result = await captureScreenshotMutation.mutateAsync(agentId);
setScreenshotCommandId(result.command_id);
toast.success('Screenshot capture requested');
} catch (error: any) {
toast.error(`Failed to capture screenshot: ${error.message || 'Unknown error'}`);
}
};
// Extract base64 screenshot from command result
const screenshotImage = screenshotCommand?.status === 'completed'
? screenshotCommand?.result?.stdout || null
: null;
// Handle rapid polling toggle
const handleRapidPollingToggle = async (agentId: string, enabled: boolean, durationMinutes?: number) => {
// Prevent multiple clicks
@ -796,8 +816,93 @@ const Agents: React.FC = () => {
)}
</div>
{/* Integrations — observed by the agent, rendered above system info */}
<AgentIntegrations metadata={selectedAgent.metadata} />
{/* Screen square — screenshot capture / Sunshine connection link */}
{(() => {
const integrations = readIntegrations(selectedAgent.metadata);
const sunshine = integrations.sunshine;
const state = resolveState(sunshine);
const live = state === 'active';
const running = state === 'running';
const sunshineReady = live || running;
const isCapturing = captureScreenshotMutation.isPending;
const isPolling = screenshotCommand && !['completed', 'failed', 'timed_out', 'cancelled'].includes(screenshotCommand.status);
// Determine what the square shows
const hasImage = !!screenshotImage;
const squareClickable = sunshineReady || !isCapturing;
return (
<div className="mb-6">
<div
className={cn(
'relative aspect-video w-full max-w-sm overflow-hidden rounded border',
'bg-gradient-to-br from-slate-800 to-slate-900 border-slate-700',
'flex flex-col items-center justify-center gap-2 text-slate-400',
squareClickable && 'cursor-pointer hover:from-slate-700 hover:to-slate-800 transition-colors'
)}
onClick={() => {
if (sunshineReady && sunshine?.web_ui) {
window.open(sunshine.web_ui, '_blank', 'noreferrer');
} else if (!isCapturing && !isPolling) {
handleCaptureScreenshot(selectedAgent.id);
}
}}
>
{hasImage ? (
<img
src={`data:image/png;base64,${screenshotImage}`}
alt="Agent screenshot"
className="absolute inset-0 w-full h-full object-contain"
/>
) : (
<>
<MonitorPlay className="h-10 w-10 opacity-70" />
<span className="text-xs">
{isCapturing ? 'Requesting...'
: isPolling ? 'Capturing...'
: sunshineReady ? 'Open stream host'
: 'Click to capture'}
</span>
</>
)}
{/* LIVE marker when Sunshine session is active */}
{live && (
<span className="absolute top-2 left-2 flex items-center gap-1 rounded bg-red-600/90 px-1.5 py-0.5 text-[10px] font-medium text-white">
<span className="h-1.5 w-1.5 rounded-full bg-white animate-pulse" />
LIVE
</span>
)}
{/* Sunshine status badge */}
{sunshineReady && (
<span className="absolute top-2 right-2 rounded bg-black/50 px-1.5 py-0.5 text-[10px] font-medium text-white">
{live ? sunshine?.client_name || 'Connected' : 'Sunshine running'}
</span>
)}
{/* Loading spinner overlay */}
{(isCapturing || isPolling) && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<RefreshCw className="h-8 w-8 text-white animate-spin" />
</div>
)}
</div>
{/* Caption row beneath the square */}
<div className="flex items-center gap-2 mt-1.5 text-xs text-gray-500">
{sunshine?.version && <span>Sunshine v{sunshine.version}</span>}
{screenshotCommand?.status === 'completed' && screenshotCommand?.completed_at && (
<span className="ml-auto">Captured {formatRelativeTime(screenshotCommand.completed_at)}</span>
)}
{screenshotCommand?.status === 'failed' && (
<span className="text-red-500 ml-auto">Capture failed</span>
)}
</div>
</div>
);
})()}
{/* System info */}
<div className="card">

View file

@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: "dist-desktop",
emptyOutDir: true,
rollupOptions: {
input: path.resolve(__dirname, "index.desktop.html"),
},
},
server: {
port: 3001,
strictPort: true,
},
})