mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Fix mobile relay runtime startup
This commit is contained in:
parent
c0ac0762da
commit
df67c01a64
8 changed files with 245 additions and 36 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue