diff --git a/internal/ai/approval/store.go b/internal/ai/approval/store.go index a8471e3c3..2106c45aa 100644 --- a/internal/ai/approval/store.go +++ b/internal/ai/approval/store.go @@ -380,7 +380,7 @@ func (s *Store) GetPendingApprovals() []*ApprovalRequest { defer s.mu.RUnlock() now := time.Now() - var pending []*ApprovalRequest + pending := make([]*ApprovalRequest, 0) for _, req := range s.approvals { if req.Status == StatusPending && now.Before(req.ExpiresAt) { @@ -398,7 +398,7 @@ func (s *Store) GetPendingApprovalsForOrg(orgID string) []*ApprovalRequest { defer s.mu.RUnlock() now := time.Now() - var pending []*ApprovalRequest + pending := make([]*ApprovalRequest, 0) for _, req := range s.approvals { if !BelongsToOrg(req, orgID) { diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 98e4a5e86..02e700932 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -7215,7 +7215,12 @@ func (h *AISettingsHandler) HandleListApprovals(w http.ResponseWriter, r *http.R store := approval.GetStore() if store == nil { - writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Approval store not initialized", nil) + response := map[string]interface{}{ + "approvals": []approval.ApprovalRequest{}, + "stats": emptyApprovalStats(), + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) return } @@ -7229,6 +7234,16 @@ func (h *AISettingsHandler) HandleListApprovals(w http.ResponseWriter, r *http.R json.NewEncoder(w).Encode(response) } +func emptyApprovalStats() map[string]int { + return map[string]int{ + "pending": 0, + "approved": 0, + "denied": 0, + "expired": 0, + "executions": 0, + } +} + // HandleGetApproval returns a specific approval request. func (h *AISettingsHandler) HandleGetApproval(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index 6b4874e57..bff7ec26f 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -3122,6 +3122,27 @@ func TestAISettingsHandler_Approvals(t *testing.T) { assert.Equal(t, 1, resp.Stats["pending"]) }) + t.Run("HandleListApprovalsWithoutStoreReturnsEmptyCollections", func(t *testing.T) { + approval.SetStore(nil) + t.Cleanup(func() { approval.SetStore(approvalStore) }) + + req := newLoopbackRequest(http.MethodGet, "/api/ai/approvals", nil) + rec := httptest.NewRecorder() + handler.HandleListApprovals(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + var resp struct { + Approvals []approval.ApprovalRequest `json:"approvals"` + Stats map[string]int `json:"stats"` + } + err := json.Unmarshal(rec.Body.Bytes(), &resp) + require.NoError(t, err) + require.NotNil(t, resp.Approvals) + require.Len(t, resp.Approvals, 0) + assert.Equal(t, 0, resp.Stats["pending"]) + assert.Equal(t, 0, resp.Stats["executions"]) + }) + t.Run("HandleApproveCommand", func(t *testing.T) { req := newLoopbackRequest(http.MethodPost, "/api/ai/approvals/"+appID+"/approve", nil) rec := httptest.NewRecorder() diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 8df8e8b4b..d082e0875 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -3655,20 +3655,17 @@ func TestContract_ApprovalJSONSnapshot(t *testing.T) { } func TestContract_ApprovalListResponseJSONSnapshot(t *testing.T) { - payload := map[string]any{ - "approvals": []approval.ApprovalRequest{}, - "stats": map[string]int{ - "approved": 0, - "denied": 0, - "executions": 0, - "expired": 0, - "pending": 0, - }, - } + previousStore := approval.GetStore() + approval.SetStore(nil) + t.Cleanup(func() { approval.SetStore(previousStore) }) - got, err := json.Marshal(payload) - if err != nil { - t.Fatalf("marshal approval list response: %v", err) + handler := newTestAISettingsHandler(&config.Config{DataPath: t.TempDir()}, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/api/ai/approvals", nil) + rec := httptest.NewRecorder() + handler.HandleListApprovals(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("approval list status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) } const want = `{ @@ -3682,7 +3679,7 @@ func TestContract_ApprovalListResponseJSONSnapshot(t *testing.T) { } }` - assertJSONSnapshot(t, got, want) + assertJSONSnapshot(t, rec.Body.Bytes(), want) } func TestContract_HostedSignupResponseJSONSnapshot(t *testing.T) { diff --git a/internal/api/relay_hosted_runtime.go b/internal/api/relay_hosted_runtime.go index 4c764641d..6d3c0722d 100644 --- a/internal/api/relay_hosted_runtime.go +++ b/internal/api/relay_hosted_runtime.go @@ -21,7 +21,12 @@ func (r *Router) loadRelayConfigForRuntime(ctx context.Context) (*relay.Config, return nil, err } - return r.ensureHostedRelayConfig(ctx, cfg) + cfg, err = r.ensureHostedRelayConfig(ctx, cfg) + if err != nil { + return nil, err + } + + return r.ensureEnabledRelayIdentityConfig(cfg) } func (r *Router) ensureHostedRelayConfig(ctx context.Context, cfg *relay.Config) (*relay.Config, error) { @@ -56,16 +61,11 @@ func (r *Router) ensureHostedRelayConfig(ctx context.Context, cfg *relay.Config) effective.InstanceSecret = instanceHost changed = true } - if strings.TrimSpace(effective.IdentityPrivateKey) == "" || - strings.TrimSpace(effective.IdentityPublicKey) == "" || - strings.TrimSpace(effective.IdentityFingerprint) == "" { - privKey, pubKey, fingerprint, err := relay.GenerateIdentityKeyPair() - if err != nil { - return nil, fmt.Errorf("generate hosted relay identity: %w", err) - } - effective.IdentityPrivateKey = privKey - effective.IdentityPublicKey = pubKey - effective.IdentityFingerprint = fingerprint + identityGenerated, err := ensureRelayIdentityKeyPair(&effective) + if err != nil { + return nil, fmt.Errorf("generate hosted relay identity: %w", err) + } + if identityGenerated { changed = true } @@ -94,6 +94,52 @@ func (r *Router) shouldAutoBootstrapHostedRelayConfig(cfg *relay.Config) bool { strings.TrimSpace(cfg.IdentityFingerprint) == "" } +func (r *Router) ensureEnabledRelayIdentityConfig(cfg *relay.Config) (*relay.Config, error) { + if cfg == nil { + cfg = relay.DefaultConfig() + } + if !cfg.Enabled { + return cfg, nil + } + + effective := *cfg + identityGenerated, err := ensureRelayIdentityKeyPair(&effective) + if err != nil { + return nil, fmt.Errorf("generate relay identity: %w", err) + } + if !identityGenerated { + return cfg, nil + } + + if r != nil && r.persistence != nil { + if err := r.persistence.SaveRelayConfig(effective); err != nil { + return nil, fmt.Errorf("save relay identity config: %w", err) + } + } + + return &effective, nil +} + +func ensureRelayIdentityKeyPair(cfg *relay.Config) (bool, error) { + if cfg == nil { + return false, nil + } + if strings.TrimSpace(cfg.IdentityPrivateKey) != "" && + strings.TrimSpace(cfg.IdentityPublicKey) != "" && + strings.TrimSpace(cfg.IdentityFingerprint) != "" { + return false, nil + } + + privKey, pubKey, fingerprint, err := relay.GenerateIdentityKeyPair() + if err != nil { + return false, err + } + cfg.IdentityPrivateKey = privKey + cfg.IdentityPublicKey = pubKey + cfg.IdentityFingerprint = fingerprint + return true, nil +} + func (r *Router) relayRegistrationToken(ctx context.Context) string { if r == nil || r.licenseHandlers == nil { return "" diff --git a/internal/api/relay_hosted_runtime_test.go b/internal/api/relay_hosted_runtime_test.go index 9fed819e9..f469d90a2 100644 --- a/internal/api/relay_hosted_runtime_test.go +++ b/internal/api/relay_hosted_runtime_test.go @@ -53,6 +53,90 @@ func TestLoadRelayConfigForRuntime_HostedEntitlementAutoBootstrapsRelay(t *testi } } +func TestLoadRelayConfigForRuntime_EnvEnabledRelayGeneratesPersistedIdentity(t *testing.T) { + t.Setenv(relay.EnvRelayEnabled, "true") + t.Setenv(relay.EnvRelayServerURL, "wss://relay.internal.example/ws/instance") + + persistence := config.NewConfigPersistence(t.TempDir()) + router := &Router{persistence: persistence} + + cfg, err := router.loadRelayConfigForRuntime(context.Background()) + if err != nil { + t.Fatalf("loadRelayConfigForRuntime() error = %v", err) + } + if cfg == nil { + t.Fatal("loadRelayConfigForRuntime() returned nil config") + } + if !cfg.Enabled { + t.Fatal("expected env-enabled relay config to remain enabled") + } + if cfg.ServerURL != "wss://relay.internal.example/ws/instance" { + t.Fatalf("server_url = %q, want env override", cfg.ServerURL) + } + if cfg.IdentityPrivateKey == "" || cfg.IdentityPublicKey == "" || cfg.IdentityFingerprint == "" { + t.Fatal("expected env-enabled relay config to generate full identity") + } + + t.Setenv(relay.EnvRelayEnabled, "") + t.Setenv(relay.EnvRelayServerURL, "") + + persisted, err := persistence.LoadRelayConfig() + if err != nil { + t.Fatalf("LoadRelayConfig() error = %v", err) + } + if persisted == nil || !persisted.Enabled { + t.Fatal("expected generated relay identity config to be persisted enabled") + } + if persisted.ServerURL != cfg.ServerURL { + t.Fatalf("persisted server_url = %q, want %q", persisted.ServerURL, cfg.ServerURL) + } + if persisted.IdentityPrivateKey == "" || persisted.IdentityPublicKey == "" || persisted.IdentityFingerprint == "" { + t.Fatal("expected persisted relay config to include full identity") + } +} + +func TestLoadRelayConfigForRuntime_EnabledPartialIdentityRegeneratesFullIdentity(t *testing.T) { + persistence := config.NewConfigPersistence(t.TempDir()) + partial := relay.Config{ + Enabled: true, + ServerURL: "wss://relay.example.test/ws/instance", + InstanceSecret: "relay-instance-secret", + IdentityPrivateKey: "private-key-without-public-material", + } + if err := persistence.SaveRelayConfig(partial); err != nil { + t.Fatalf("SaveRelayConfig() error = %v", err) + } + + router := &Router{persistence: persistence} + cfg, err := router.loadRelayConfigForRuntime(context.Background()) + if err != nil { + t.Fatalf("loadRelayConfigForRuntime() error = %v", err) + } + if cfg == nil { + t.Fatal("loadRelayConfigForRuntime() returned nil config") + } + if !cfg.Enabled { + t.Fatal("expected enabled relay config to remain enabled") + } + if cfg.IdentityPrivateKey == partial.IdentityPrivateKey { + t.Fatal("expected partial relay identity to be replaced with a complete keypair") + } + if cfg.IdentityPrivateKey == "" || cfg.IdentityPublicKey == "" || cfg.IdentityFingerprint == "" { + t.Fatal("expected enabled relay config to include full identity") + } + + persisted, err := persistence.LoadRelayConfig() + if err != nil { + t.Fatalf("LoadRelayConfig() error = %v", err) + } + if persisted == nil || persisted.IdentityPrivateKey != cfg.IdentityPrivateKey { + t.Fatal("expected repaired relay identity to be persisted") + } + if persisted.IdentityPublicKey == "" || persisted.IdentityFingerprint == "" { + t.Fatal("expected persisted relay identity to be complete") + } +} + func TestLoadRelayConfigForRuntime_DoesNotOverrideExplicitDisabledRelayConfig(t *testing.T) { router, _, instanceHost := newHostedRelayRuntimeTestRouter(t) diff --git a/internal/api/router.go b/internal/api/router.go index f4bea7eab..5f7c2e9a2 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -752,6 +752,9 @@ func (r *Router) setupRoutes() { } return svc.CostStore() }) + // Mobile polls approvals as part of its normal paired runtime. Keep the + // approval store available even when AI chat is disabled or not configured. + r.aiHandler.ensureApprovalStore(r.config.DataPath) // AI-powered infrastructure discovery handlers // Note: The actual service is wired up later via SetDiscoveryService @@ -2577,6 +2580,9 @@ func (r *Router) shutdownBackgroundWorkers() { if r.aiSettingsHandler != nil { r.aiSettingsHandler.StopServices() } + if r.aiHandler != nil { + r.aiHandler.clearApprovalStore() + } if r.trueNASPoller != nil { r.trueNASPoller.Stop() } @@ -3431,20 +3437,20 @@ func (r *Router) handleUpdateRelayConfig(w http.ResponseWriter, req *http.Reques cfg.InstanceSecret = *update.InstanceSecret } - // Generate identity keypair on first enable + // Generate a full identity keypair on first enable, or repair a partial + // identity so the running client can sign mobile key exchanges. identityGenerated := false - if cfg.Enabled && cfg.IdentityPrivateKey == "" { - privKey, pubKey, fp, err := relay.GenerateIdentityKeyPair() + if cfg.Enabled { + generated, err := ensureRelayIdentityKeyPair(&cfg) if err != nil { log.Error().Err(err).Msg("Failed to generate relay identity keypair") http.Error(w, "failed to generate identity keypair", http.StatusInternalServerError) return } - cfg.IdentityPrivateKey = privKey - cfg.IdentityPublicKey = pubKey - cfg.IdentityFingerprint = fp - identityGenerated = true - log.Info().Str("fingerprint", fp).Msg("Generated relay instance identity keypair") + identityGenerated = generated + if identityGenerated { + log.Info().Str("fingerprint", cfg.IdentityFingerprint).Msg("Generated relay instance identity keypair") + } } if err := r.persistence.SaveRelayConfig(cfg); err != nil { diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index b3b62787d..020f40141 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -16,6 +16,7 @@ import ( "github.com/gorilla/websocket" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" @@ -1559,6 +1560,45 @@ func TestRelayMobileAccessScopeAllowsGovernedMobileRuntimeEndpoints(t *testing.T } } +func TestRelayMobileApprovalsListReturnsEmptyBeforeAIChatConfigured(t *testing.T) { + rawToken := "relay-mobile-approvals-empty-token-123.12345678" + record := newTokenRecord(t, rawToken, []string{config.ScopeRelayMobileAccess}, nil) + cfg := newTestConfigWithTokens(t, record) + + previousStore := approval.GetStore() + approval.SetStore(nil) + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + t.Cleanup(func() { + router.shutdownBackgroundWorkers() + approval.SetStore(previousStore) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/approvals", nil) + req.Header.Set("X-API-Token", rawToken) + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("approvals list status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + var response struct { + Approvals []approval.ApprovalRequest `json:"approvals"` + Stats map[string]int `json:"stats"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode approvals list response: %v", err) + } + if response.Approvals == nil { + t.Fatal("approvals must be an empty array, not null") + } + if len(response.Approvals) != 0 { + t.Fatalf("approvals length = %d, want 0", len(response.Approvals)) + } + if response.Stats["pending"] != 0 || response.Stats["approved"] != 0 || response.Stats["denied"] != 0 || response.Stats["expired"] != 0 || response.Stats["executions"] != 0 { + t.Fatalf("expected zero approval stats, got %#v", response.Stats) + } +} + func TestRelayMobileAccessScopeDeniesAdjacentAIRoutes(t *testing.T) { rawToken := "relay-mobile-adjacent-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeRelayMobileAccess}, nil)