From 9cd53814a308270302e588c12ec7f43fa3b9e9ab Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 13 Jan 2026 23:38:20 +0000 Subject: [PATCH] feat(alerts): add per-volume disk thresholds for host agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow users to set custom disk usage thresholds per mounted filesystem on host agents, rather than applying a single threshold to all volumes. This addresses NAS/NVR use cases where some volumes (e.g., NVR storage) intentionally run at 99% while others need strict monitoring. Backend: - Check for disk-specific overrides before using HostDefaults.Disk - Override key format: host:/disk: - Support both custom thresholds and disable per-disk Frontend: - Add 'hostDisk' resource type - Add "Host Disks" collapsible section in Thresholds → Hosts tab - Group disks by host for easier navigation Closes #1103 --- frontend-modern/src/App.tsx | 2 +- .../src/components/Alerts/Thresholds/types.ts | 4 + .../src/components/Alerts/ThresholdsTable.tsx | 166 +++++++++++++++++- frontend-modern/src/pages/Alerts.tsx | 1 + go.mod | 3 +- internal/ai/service.go | 11 ++ internal/alerts/alerts.go | 75 ++++++-- internal/api/ai_handlers.go | 21 ++- internal/api/router.go | 63 +++++++ internal/config/persistence.go | 7 +- scripts/hot-dev.sh | 13 +- scripts/sync-production-config.sh | 14 ++ 12 files changed, 351 insertions(+), 29 deletions(-) diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index d9f3bb5815..b359c09433 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -48,7 +48,7 @@ import { TokenRevealDialog } from './components/TokenRevealDialog'; import { useAlertsActivation } from './stores/alertsActivation'; import { UpdateProgressModal } from './components/UpdateProgressModal'; import type { UpdateStatus } from './api/updates'; -import { AIChat } from './components/AI/AIChat'; +import { AIChat } from './components/AI/Chat'; import { AIStatusIndicator } from './components/AI/AIStatusIndicator'; import { aiChatStore } from './stores/aiChat'; import { useResourcesAsLegacy } from './hooks/useResources'; diff --git a/frontend-modern/src/components/Alerts/Thresholds/types.ts b/frontend-modern/src/components/Alerts/Thresholds/types.ts index 959e1fc1f3..4151f60f08 100644 --- a/frontend-modern/src/components/Alerts/Thresholds/types.ts +++ b/frontend-modern/src/components/Alerts/Thresholds/types.ts @@ -22,6 +22,7 @@ export type ResourceType = | 'pbs' | 'pmg' | 'hostAgent' + | 'hostDisk' | 'dockerHost' | 'dockerContainer'; @@ -347,6 +348,9 @@ export const RESOURCE_COLUMNS: Record = { { key: 'memory', label: 'Memory %', unit: '%' }, { key: 'disk', label: 'Disk %', unit: '%' }, ], + hostDisk: [ + { key: 'disk', label: 'Disk %', unit: '%' }, + ], dockerHost: [], dockerContainer: [ { key: 'cpu', label: 'CPU %', unit: '%' }, diff --git a/frontend-modern/src/components/Alerts/ThresholdsTable.tsx b/frontend-modern/src/components/Alerts/ThresholdsTable.tsx index 9a4d44292a..39c38d1644 100644 --- a/frontend-modern/src/components/Alerts/ThresholdsTable.tsx +++ b/frontend-modern/src/components/Alerts/ThresholdsTable.tsx @@ -47,6 +47,7 @@ type OverrideType = | 'guest' | 'node' | 'hostAgent' + | 'hostDisk' | 'storage' | 'pbs' | 'pmg' @@ -861,6 +862,112 @@ export function ThresholdsTable(props: ThresholdsTableProps) { return hosts; }, []); + // Helper function to create host disk resource ID (matches backend format) + const hostDiskResourceID = (hostId: string, mountpoint: string, device?: string): string => { + // Use mountpoint if available, otherwise device + let label = mountpoint?.trim() || device?.trim() || 'disk'; + // Sanitize the label (replace slashes with underscores, similar to backend sanitizeHostComponent) + label = label.replace(/\//g, '_').replace(/^_/, ''); + return `host:${hostId}/disk:${label}`; + }; + + // Process host disks with their overrides + const hostDisksWithOverrides = createMemo((prev = []) => { + if (editingId()) { + return prev; + } + + const search = searchTerm().toLowerCase(); + const overridesMap = new Map((props.overrides() ?? []).map((o) => [o.id, o])); + const seen = new Set(); + const disks: Resource[] = []; + + // Extract disks from all hosts + (props.hosts ?? []).forEach((host) => { + const hostDisplayName = host.displayName?.trim() || host.hostname || host.id; + + (host.disks ?? []).forEach((disk) => { + const diskLabel = disk.mountpoint?.trim() || disk.device?.trim() || 'disk'; + const resourceId = hostDiskResourceID(host.id, disk.mountpoint || '', disk.device); + const override = overridesMap.get(resourceId); + + const hasCustomThresholds = + override?.thresholds?.disk !== undefined && + override.thresholds.disk !== props.hostDefaults.disk; + + seen.add(resourceId); + + disks.push({ + id: resourceId, + name: diskLabel, + displayName: diskLabel, + rawName: disk.device || diskLabel, + type: 'hostDisk' as const, + resourceType: 'Host Disk', + host: host.id, + node: hostDisplayName, + instance: disk.type || '', + status: host.status || 'online', + hasOverride: hasCustomThresholds || Boolean(override?.disabled), + disabled: override?.disabled || false, + thresholds: override?.thresholds || {}, + defaults: { disk: props.hostDefaults.disk }, + subtitle: `${((disk.used || 0) / 1024 / 1024 / 1024).toFixed(1)} / ${((disk.total || 0) / 1024 / 1024 / 1024).toFixed(1)} GB`, + } satisfies Resource); + }); + }); + + // Include any hostDisk overrides for disks that are no longer present + (props.overrides() ?? []) + .filter((override) => override.type === 'hostDisk' && !seen.has(override.id)) + .forEach((override) => { + const name = override.name || override.id; + disks.push({ + id: override.id, + name, + displayName: name, + rawName: name, + type: 'hostDisk' as const, + resourceType: 'Host Disk', + host: '', + node: 'Unknown Host', + instance: '', + status: 'unknown', + hasOverride: true, + disabled: override.disabled || false, + thresholds: override.thresholds || {}, + defaults: { disk: props.hostDefaults.disk }, + }); + }); + + if (search) { + return disks.filter( + (d) => d.name.toLowerCase().includes(search) || d.node?.toLowerCase().includes(search), + ); + } + + return disks; + }, []); + + // Group host disks by their host + const hostDisksGroupedByHost = createMemo>(() => { + const grouped: Record = {}; + hostDisksWithOverrides().forEach((disk) => { + const key = disk.node?.trim() || 'Unknown Host'; + if (!grouped[key]) { + grouped[key] = []; + } + grouped[key].push(disk); + }); + + // Sort disks within each host by name + Object.values(grouped).forEach((resources) => { + resources.sort((a, b) => a.name.localeCompare(b.name)); + }); + + return grouped; + }); + // Process Docker hosts with their overrides (primarily for connectivity toggles) const dockerHostsWithOverrides = createMemo((prev = []) => { if (editingId()) { @@ -1640,6 +1747,13 @@ export function ThresholdsTable(props: ThresholdsTableProps) { overrides: countOverrides(hostAgentsWithOverrides()), tab: 'hosts' as const, }, + { + key: 'hostDisks' as const, + label: 'Host Disks', + total: hostDisksWithOverrides().length, + overrides: countOverrides(hostDisksWithOverrides()), + tab: 'hosts' as const, + }, { key: 'storage' as const, label: 'Storage', @@ -1721,6 +1835,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) { const allResources = [ ...nodesWithOverrides(), ...hostAgentsWithOverrides(), + ...hostDisksWithOverrides(), ...dockerHostsWithOverrides(), ...allGuests, ...allDockerContainers, @@ -2065,6 +2180,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) { ...storageWithOverrides(), ...pbsServersWithOverrides(), ...hostAgentsWithOverrides(), + ...hostDisksWithOverrides(), ]; const resource = allResources.find((r) => r.id === resourceId); if ( @@ -2073,7 +2189,8 @@ export function ThresholdsTable(props: ThresholdsTableProps) { resource.type !== 'storage' && resource.type !== 'pbs' && resource.type !== 'dockerContainer' && - resource.type !== 'hostAgent') + resource.type !== 'hostAgent' && + resource.type !== 'hostDisk') ) return; @@ -3188,6 +3305,53 @@ export function ThresholdsTable(props: ThresholdsTableProps) { /> + + + toggleSection('hostDisks')} + icon={} + isGloballyDisabled={props.disableAllHosts()} + emptyMessage="No host disks found. Host agents with mounted filesystems will appear here." + > +
+ { + if (typeof value === 'function') { + const newValue = value({ disk: props.hostDefaults.disk }); + props.setHostDefaults((prev) => ({ ...prev, disk: newValue.disk })); + } else { + props.setHostDefaults((prev) => ({ ...prev, disk: value.disk })); + } + }} + setHasUnsavedChanges={props.setHasUnsavedChanges} + /> +
+
+
diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index 0bda0643a5..894fff80b5 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -207,6 +207,7 @@ type OverrideType = | 'guest' | 'node' | 'hostAgent' + | 'hostDisk' | 'storage' | 'pbs' | 'pmg' diff --git a/go.mod b/go.mod index b5ec77200b..083f9f7f8e 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/crewjam/saml v0.5.1 github.com/docker/docker v28.5.2+incompatible github.com/fsnotify/fsnotify v1.9.0 + github.com/go-pdf/fpdf v0.9.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 @@ -60,9 +61,7 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect - github.com/go-pdf/fpdf v0.9.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.7.0 // indirect diff --git a/internal/ai/service.go b/internal/ai/service.go index 71953bafb4..3b0b29376d 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -226,6 +226,17 @@ func (s *Service) GetPatrolService() *PatrolService { return s.patrolService } +// GetRemediationLog returns the remediation log from the patrol service +func (s *Service) GetRemediationLog() *RemediationLog { + s.mu.RLock() + patrol := s.patrolService + s.mu.RUnlock() + if patrol == nil { + return nil + } + return patrol.GetRemediationLog() +} + // GetAlertTriggeredAnalyzer returns the alert-triggered analyzer for token-efficient real-time analysis func (s *Service) GetAlertTriggeredAnalyzer() *AlertTriggeredAnalyzer { s.mu.RLock() diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index 509125c6bc..e0b8acac56 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -2837,27 +2837,66 @@ func (m *Manager) CheckHost(host models.Host) { } seenDisks := make(map[string]struct{}, len(host.Disks)) - if thresholds.Disk != nil && thresholds.Disk.Trigger > 0 { - for _, disk := range host.Disks { - diskResourceID, diskName := hostDiskResourceID(host, disk) - seenDisks[diskResourceID] = struct{}{} + for _, disk := range host.Disks { + diskResourceID, diskName := hostDiskResourceID(host, disk) + seenDisks[diskResourceID] = struct{}{} - diskMetadata := cloneMetadata(baseMetadata) - diskMetadata["metric"] = "disk" - diskMetadata["mountpoint"] = disk.Mountpoint - diskMetadata["device"] = disk.Device - diskMetadata["diskType"] = disk.Type - diskMetadata["diskUsagePercent"] = disk.Usage - if disk.Total > 0 { - diskMetadata["diskTotalBytes"] = disk.Total - diskMetadata["diskUsedBytes"] = disk.Used - diskMetadata["diskFreeBytes"] = disk.Free + // Check for disk-specific override + m.mu.RLock() + diskOverride, hasDiskOverride := m.config.Overrides[diskResourceID] + m.mu.RUnlock() + + // Determine the effective disk threshold + var effectiveDiskThreshold *HysteresisThreshold + if hasDiskOverride { + // If disk is disabled via override, skip alerting + if diskOverride.Disabled { + m.clearAlert(fmt.Sprintf("host-%s-disk-%s", host.ID, sanitizeHostComponent(disk.Mountpoint))) + continue + } + // Use disk-specific threshold if set + if diskOverride.Disk != nil { + effectiveDiskThreshold = ensureHysteresisThreshold(diskOverride.Disk) + } else if diskOverride.DiskLegacy != nil { + effectiveDiskThreshold = m.convertLegacyThreshold(diskOverride.DiskLegacy) } - - m.checkMetric(diskResourceID, diskName, nodeName, instanceName, "Host Disk", "disk", disk.Usage, thresholds.Disk, &metricOptions{Metadata: diskMetadata}) } - } else { - m.clearHostDiskAlerts(host.ID) + // Fall back to host-level threshold + if effectiveDiskThreshold == nil { + effectiveDiskThreshold = thresholds.Disk + } + + // Skip if no threshold configured or threshold is disabled (trigger=0) + if effectiveDiskThreshold == nil || effectiveDiskThreshold.Trigger <= 0 { + continue + } + + diskMetadata := cloneMetadata(baseMetadata) + diskMetadata["metric"] = "disk" + diskMetadata["mountpoint"] = disk.Mountpoint + diskMetadata["device"] = disk.Device + diskMetadata["diskType"] = disk.Type + diskMetadata["diskUsagePercent"] = disk.Usage + if disk.Total > 0 { + diskMetadata["diskTotalBytes"] = disk.Total + diskMetadata["diskUsedBytes"] = disk.Used + diskMetadata["diskFreeBytes"] = disk.Free + } + + m.checkMetric(diskResourceID, diskName, nodeName, instanceName, "Host Disk", "disk", disk.Usage, effectiveDiskThreshold, &metricOptions{Metadata: diskMetadata}) + } + + // Clear all disk alerts if host-level disk alerting is completely disabled and no disk-specific overrides + if thresholds.Disk == nil || thresholds.Disk.Trigger <= 0 { + // Only clear alerts for disks that don't have their own overrides + m.mu.RLock() + for _, disk := range host.Disks { + diskResourceID, _ := hostDiskResourceID(host, disk) + if _, hasDiskOverride := m.config.Overrides[diskResourceID]; !hasDiskOverride { + m.clearAlert(fmt.Sprintf("host-%s-disk-%s", host.ID, sanitizeHostComponent(disk.Mountpoint))) + } + } + m.mu.RUnlock() } m.cleanupHostDiskAlerts(host, seenDisks) diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index a585caf911..e65564d4c0 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -31,10 +31,11 @@ import ( // AISettingsHandler handles AI settings endpoints type AISettingsHandler struct { - config *config.Config - persistence *config.ConfigPersistence - aiService *ai.Service - agentServer *agentexec.Server + config *config.Config + persistence *config.ConfigPersistence + aiService *ai.Service + agentServer *agentexec.Server + onModelChange func() // Called when model or other OpenCode-affecting settings change } // NewAISettingsHandler creates a new AI settings handler @@ -161,6 +162,12 @@ func (h *AISettingsHandler) SetLicenseChecker(checker ai.LicenseChecker) { h.aiService.SetLicenseChecker(checker) } +// SetOnModelChange sets a callback to be invoked when model settings change +// Used by Router to trigger OpenCode sidecar restart +func (h *AISettingsHandler) SetOnModelChange(callback func()) { + h.onModelChange = callback +} + // AISettingsResponse is returned by GET /api/settings/ai // API keys are masked for security type AISettingsResponse struct { @@ -602,6 +609,12 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt analyzer.SetEnabled(settings.AlertTriggeredAnalysis) } + // Trigger OpenCode sidecar restart if model changed + // This ensures the new model is picked up by the sidecar + if h.onModelChange != nil && (req.Model != nil || req.ChatModel != nil) { + h.onModelChange() + } + log.Info(). Bool("enabled", settings.Enabled). Str("provider", settings.Provider). diff --git a/internal/api/router.go b/internal/api/router.go index e4ebb98a41..8055165bc6 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -28,6 +28,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/agentbinaries" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/mcp" "github.com/rcourtman/pulse-go-rewrite/internal/ai/opencode" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/config" @@ -73,6 +74,8 @@ type Router struct { persistence *config.ConfigPersistence oidcMu sync.Mutex oidcService *OIDCService + samlManager *SAMLServiceManager + ssoConfig *config.SSOConfig authorizer auth.Authorizer wrapped http.Handler serverVersion string @@ -157,6 +160,9 @@ func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, wsHub *websocket auth.SetAdminUser(cfg.AuthUser) } + // Initialize SAML manager (baseURL will be set dynamically on first use) + r.samlManager = NewSAMLServiceManager("") + r.initializeBootstrapToken() r.setupRoutes() @@ -1248,6 +1254,10 @@ func (r *Router) setupRoutes() { r.aiHandler = NewAIHandler(r.config, r.persistence, r.agentExecServer) // Wire license checker for Pro feature gating (AI Patrol, Alert Analysis, Auto-Fix) r.aiSettingsHandler.SetLicenseChecker(r.licenseHandlers.Service()) + // Wire model change callback to restart OpenCode sidecar when model is changed + r.aiSettingsHandler.SetOnModelChange(func() { + r.RestartOpenCodeAI(context.Background()) + }) // Wire license checker for alert manager Pro features (Update Alerts) if r.monitor != nil { alertMgr := r.monitor.GetAlertManager() @@ -1849,6 +1859,9 @@ func (r *Router) StartOpenCodeAI(ctx context.Context) { return } + // Wire up MCP tool providers so AI can access real data + r.wireOpenCodeProviders() + // Wire up OpenCode patrol if UseOpenCode is enabled aiCfg := r.aiHandler.GetAIConfig() if aiCfg != nil && aiCfg.UseOpenCode && r.aiHandler.IsRunning() { @@ -1868,6 +1881,44 @@ func (r *Router) StartOpenCodeAI(ctx context.Context) { } } +// wireOpenCodeProviders wires up all MCP tool providers for OpenCode +func (r *Router) wireOpenCodeProviders() { + if r.aiHandler == nil || !r.aiHandler.IsRunning() { + return + } + + service := r.aiHandler.GetService() + if service == nil { + return + } + + // Wire alert provider + if r.monitor != nil { + if alertManager := r.monitor.GetAlertManager(); alertManager != nil { + alertAdapter := mcp.NewAlertManagerMCPAdapter(alertManager) + if alertAdapter != nil { + service.SetAlertProvider(alertAdapter) + log.Debug().Msg("OpenCode: Alert provider wired") + } + } + } + + // Wire findings provider from patrol service + if r.aiSettingsHandler != nil { + if patrolSvc := r.aiSettingsHandler.GetAIService().GetPatrolService(); patrolSvc != nil { + if findingsStore := patrolSvc.GetFindings(); findingsStore != nil { + findingsAdapter := ai.NewFindingsMCPAdapter(findingsStore) + if findingsAdapter != nil { + service.SetFindingsProvider(findingsAdapter) + log.Debug().Msg("OpenCode: Findings provider wired") + } + } + } + } + + log.Info().Msg("OpenCode MCP tool providers wired") +} + // StopOpenCodeAI stops the OpenCode-based AI service func (r *Router) StopOpenCodeAI(ctx context.Context) { if r.aiHandler != nil { @@ -1877,6 +1928,18 @@ func (r *Router) StopOpenCodeAI(ctx context.Context) { } } +// RestartOpenCodeAI restarts the OpenCode service with updated configuration +// Call this when AI settings change that affect the sidecar (e.g., model selection) +func (r *Router) RestartOpenCodeAI(ctx context.Context) { + if r.aiHandler != nil { + if err := r.aiHandler.Restart(ctx); err != nil { + log.Error().Err(err).Msg("Failed to restart OpenCode AI service") + } else { + log.Info().Msg("OpenCode AI service restarted with new configuration") + } + } +} + // startBaselineLearning runs a background loop that learns baselines from metrics history // This enables anomaly detection by understanding what "normal" looks like for each resource func (r *Router) startBaselineLearning(ctx context.Context, store *ai.BaselineStore, metricsHistory *monitoring.MetricsHistory) { diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 4e1e6916b3..682726a9db 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -1680,7 +1680,12 @@ func (c *ConfigPersistence) LoadAIConfig() (*AIConfig, error) { settings.PatrolIntervalMinutes = 15 } - log.Info().Str("file", c.aiFile).Bool("enabled", settings.Enabled).Bool("patrol_enabled", settings.PatrolEnabled).Bool("alert_triggered_analysis", settings.AlertTriggeredAnalysis).Msg("AI configuration loaded") + // Environment variable override for UseOpenCode + if os.Getenv("PULSE_USE_OPENCODE") == "true" || os.Getenv("PULSE_USE_OPENCODE") == "1" { + settings.UseOpenCode = true + } + + log.Info().Str("file", c.aiFile).Bool("enabled", settings.Enabled).Bool("patrol_enabled", settings.PatrolEnabled).Bool("alert_triggered_analysis", settings.AlertTriggeredAnalysis).Bool("use_opencode", settings.UseOpenCode).Msg("AI configuration loaded") return settings, nil } diff --git a/scripts/hot-dev.sh b/scripts/hot-dev.sh index c99703fcd5..4b2ad4a30d 100755 --- a/scripts/hot-dev.sh +++ b/scripts/hot-dev.sh @@ -180,6 +180,8 @@ Frontend: http://${FRONTEND_DEV_HOST}:${FRONTEND_DEV_PORT} (Local) http://${LAN_IP}:${FRONTEND_DEV_PORT} (LAN) Backend API: ${PULSE_DEV_API_URL} +Dev Credentials: admin / admin + Mock Mode: ${PULSE_MOCK_MODE:-false} Toggle mock mode: npm run mock:on / npm run mock:off Mock config: npm run mock:edit @@ -292,7 +294,14 @@ fi FRONTEND_PORT=${PULSE_DEV_API_PORT} PORT=${PULSE_DEV_API_PORT} -export FRONTEND_PORT PULSE_DEV_API_PORT PORT +PULSE_DEV=true # Enable development mode features (needed for admin bypass etc) +PULSE_USE_OPENCODE=true # Enable OpenCode AI backend for chat +ALLOW_ADMIN_BYPASS=1 # Allow X-Admin-Bypass header in dev mode + +# Dev credentials: admin/admin (bcrypt hash of 'admin') +PULSE_AUTH_USER="admin" +PULSE_AUTH_PASS='$2a$12$j9/pl2RCHGVGvtv4wocrx.FGBczUw97ZAeO8im0.Ty.fXDGFOviWS' +export FRONTEND_PORT PULSE_DEV_API_PORT PORT PULSE_DEV PULSE_USE_OPENCODE ALLOW_ADMIN_BYPASS PULSE_AUTH_USER PULSE_AUTH_PASS # Data Directory Setup if [[ ${PULSE_MOCK_MODE:-false} == "true" ]]; then @@ -405,7 +414,7 @@ log_info "Starting backend file watcher..." fi fi - FRONTEND_PORT=${PULSE_DEV_API_PORT} PORT=${PULSE_DEV_API_PORT} PULSE_DATA_DIR=${PULSE_DATA_DIR} ./pulse & + FRONTEND_PORT=${PULSE_DEV_API_PORT} PORT=${PULSE_DEV_API_PORT} PULSE_DATA_DIR=${PULSE_DATA_DIR} PULSE_USE_OPENCODE=${PULSE_USE_OPENCODE:-true} ALLOW_ADMIN_BYPASS=${ALLOW_ADMIN_BYPASS:-1} PULSE_DEV=${PULSE_DEV:-true} ./pulse & NEW_PID=$! sleep 1 diff --git a/scripts/sync-production-config.sh b/scripts/sync-production-config.sh index 4c6cd527c5..727c79fc4a 100755 --- a/scripts/sync-production-config.sh +++ b/scripts/sync-production-config.sh @@ -156,6 +156,20 @@ if [ "$HAVE_PROD_KEY" = true ] && [ -f "$PROD_DIR/webhooks.enc" ]; then echo "✓ Synced webhook configuration" fi +# Copy API tokens (needed for agents to authenticate in dev mode) +if [ -f "$PROD_DIR/api_tokens.json" ]; then + cp -f "$PROD_DIR/api_tokens.json" "$DEV_DIR/api_tokens.json" + chmod 600 "$DEV_DIR/api_tokens.json" + echo "✓ Synced API tokens (for agent authentication)" +fi + +# Copy AI config if it exists and we have the key +if [ "$HAVE_PROD_KEY" = true ] && [ -f "$PROD_DIR/ai.enc" ]; then + cp -f "$PROD_DIR/ai.enc" "$DEV_DIR/ai.enc" + chmod 600 "$DEV_DIR/ai.enc" + echo "✓ Synced AI configuration" +fi + # Initialize empty runtime files if they don't exist touch "$DEV_DIR/sessions.json" "$DEV_DIR/csrf_tokens.json" 2>/dev/null || true echo "[]" > "$DEV_DIR/sessions.json" 2>/dev/null || true