Fix mobile relay runtime startup

This commit is contained in:
rcourtman 2026-07-01 10:38:57 +01:00
parent c0ac0762da
commit df67c01a64
8 changed files with 245 additions and 36 deletions

View file

@ -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) {

View file

@ -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 {

View file

@ -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()

View file

@ -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) {

View file

@ -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 ""

View file

@ -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)

View file

@ -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 {

View file

@ -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)