From 3f76da7932974166df100d3e97f21a1d0d4d2bd9 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 8 Jun 2026 10:05:37 +0100 Subject: [PATCH] Surface cloud operational-context sharing opt-in in AI settings Wire AIConfig.ShareOperationalContextWithCloud through /api/settings/ai so the existing chat-path opt-in (commit 32d597267) is operator-reachable, not config-file-only. Backend (internal/api/ai_handlers.go): add share_operational_context_with_cloud to the AI settings response (always serialized so a toggle can bind to the concrete value) and to the update request as an optional *bool, applied field-by-field exactly like discovery_enabled (omitted = persisted opt-in unchanged). Frontend: add a 'Share operational context with cloud models' toggle to the Assistant runtime controls, bound to the canonical useAISettingsState form and the api/ai.ts AISettings/AISettingsUpdateRequest payload. Help/summary copy (PII-free scope, hostnames/IPs/aliases stay redacted, default off, local Ollama always gets full context) lives in aiSettingsPresentation.ts. Governance: substantive ai-runtime + frontend-primitives deltas plus dependent-contract notes (api-contracts, agent-lifecycle, storage-recovery); path-policy proofs in ai_handlers_test.go (round-trip), settingsArchitecture and aiSettingsPresentation tests. JSON snapshot contracts updated for the new always-serialized field. --- .../v6/internal/subsystems/agent-lifecycle.md | 4 + .../v6/internal/subsystems/ai-runtime.md | 18 ++++ .../v6/internal/subsystems/api-contracts.md | 6 ++ .../subsystems/frontend-primitives.md | 12 +++ .../internal/subsystems/storage-recovery.md | 5 + .../Settings/AIRuntimeControlsSection.tsx | 20 ++++ .../__tests__/settingsArchitecture.test.ts | 19 ++++ .../components/Settings/useAISettingsState.ts | 4 + frontend-modern/src/types/ai.ts | 8 ++ .../__tests__/aiSettingsPresentation.test.ts | 14 +++ .../src/utils/aiSettingsPresentation.ts | 17 ++++ internal/api/ai_handlers.go | 94 +++++++++++-------- internal/api/ai_handlers_test.go | 83 ++++++++++++++++ internal/api/contract_test.go | 6 ++ 14 files changed, 271 insertions(+), 39 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index d65383245..8876fada7 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1662,6 +1662,10 @@ Visible `stream_idle` workflow progress on that same legacy Assistant SSE route, and on `/api/ai/execute/stream`, is likewise Assistant/API transport liveness only. It must not be reused as agent heartbeat, enrollment progress, installer status, command websocket liveness, or fleet freshness evidence. +The `/api/settings/ai` `share_operational_context_with_cloud` opt-in is an +Assistant privacy/runtime setting governing whether PII-free operational +context reaches cloud models; it is not agent enrollment config, installer +readiness, command reachability, or any fleet-control capability signal. Patrol finding chat handoffs follow the same ownership split: when `/api/ai/chat` resolves a `finding_id` into model-only Patrol briefing, resource, or action context, the backend-enforced `autonomous_mode:false` diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 5822e69a9..abb28e691 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -110,6 +110,24 @@ deriving an older display status from `workflowStatusHistory`. `context_prefetch.go` must instruct the Assistant to disclose the redaction in its reply and point at the `Share operational context with cloud models` setting, rather than silently degrading the answer. + The opt-in must be operator-reachable, not config-file-only. `/api/settings/ai` + round-trips `share_operational_context_with_cloud` field-by-field exactly like + `discovery_enabled`: `internal/api/ai_handlers.go` always serializes the + current value on the settings response from + `settings.ShouldShareOperationalContextWithCloud()` (no `omitempty`, so a + toggle can bind to the concrete boolean) and applies the optional request + `*bool` on update, leaving the persisted opt-in untouched when the field is + omitted. The operator surface is the `Share operational context with cloud + models` toggle in the Assistant runtime controls + (`frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx`), + bound to the canonical `useAISettingsState` form and the + `frontend-modern/src/api/ai.ts` `AISettings` / `AISettingsUpdateRequest` + payload contract. The toggle defaults off, carries the PII-free scope and + Ollama-always-full-context caveats in its help copy + (`getAISettingsCloudContextSharingHelpContent` in + `frontend-modern/src/utils/aiSettingsPresentation.ts`), and must not be + reimplemented as a local-only browser flag or a bespoke fetch outside the + canonical settings payload. 3. Add or change Pulse Assistant request flow through `internal/api/ai_handler.go`, `frontend-modern/src/api/ai.ts`, and `frontend-modern/src/api/aiChat.ts` Assistant session compaction is a runtime-backed session workflow, not a local waiting message, transcript-only UI action, or stubbed summarize diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 8274a79bb..42fa032cf 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -664,6 +664,12 @@ payload shape change when the portal presents compact client rows. then pacing the first backend `workflow_state` long enough for browser proof to verify immediate visible activity without opening a provider request. 34. `internal/api/ai_handlers.go` shared with `ai-runtime`: AI settings and remediation handlers are both an AI runtime control surface and a canonical API payload contract boundary. + The AI settings payload on `/api/settings/ai` round-trips + `share_operational_context_with_cloud` field-by-field alongside + `discovery_enabled`: the settings response always serializes the boolean + (no `omitempty`) so the operator UI can bind a toggle to its concrete + value, the update request carries it as an optional `*bool`, and an omitted + field leaves the persisted opt-in unchanged rather than resetting it. Legacy Assistant SSE routes in this handler that still use the older execute envelope, including `/api/ai/execute/stream` and `/api/ai/investigate-alert`, must preserve their existing top-level diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 05993cb31..6464e1435 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -518,6 +518,18 @@ not a replacement status card, CTA band, or page-local nested card. apply controlled `value` props after options are mounted so settings panels such as workload discovery show the persisted option instead of falling back to the first option while the collapsed summary shows a different value. + The Assistant runtime-control toggles in + `frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx` — + workload discovery and the `Share operational context with cloud models` + cloud operational-context opt-in — are settings-shell chrome bound to the + canonical `useAISettingsState` form and `/api/settings/ai` payload, not + local browser state. Each must bind its shared `Toggle` to a + `state.form.*` field, round-trip through the field-by-field settings payload + (the cloud opt-in's `share_operational_context_with_cloud` mirrors + `discovery_enabled` and defaults off), and source its label, help, and + summary copy from + `frontend-modern/src/utils/aiSettingsPresentation.ts` rather than inlining + strings or reaching for a bespoke fetch outside the canonical payload. 3. Add feature-specific presentation only when no shared primitive should own it. Feature surfaces under `frontend-modern/src/features/` that display product labels must consume the owning subsystem's presentation utilities diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index aa25eff10..cd66cfdd7 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -582,6 +582,11 @@ recovery scope, or a storage/recovery-owned secret source. investigation streams, is likewise Assistant/API transport liveness only, not recovery acquisition progress, backup task freshness, restore readiness, provider health, or storage/recovery job status. + The `/api/settings/ai` `share_operational_context_with_cloud` opt-in on that + same handler governs only whether PII-free Assistant operational context + (access commands, config/data/log paths, ports) reaches cloud models; it is + not a storage/recovery restore approval, backup freshness, recovery-scope, + or restore-command signal. Patrol finding chat handoff execution controls in `internal/api/ai_handler.go` follow the same boundary: backend-forced `autonomous_mode:false` for `finding_id` handoffs with model-only Patrol briefing, resource, or action diff --git a/frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx b/frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx index 3acfc0ea4..f06da181d 100644 --- a/frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx +++ b/frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx @@ -14,6 +14,9 @@ import { } from '@/utils/aiControlLevelPresentation'; import { AI_SETTINGS_ASSISTANT_PERMISSIONS_TITLE, + AI_SETTINGS_CLOUD_CONTEXT_SHARING_LABEL, + getAISettingsCloudContextSharingHelpContent, + getAISettingsCloudContextSharingSummary, getAISettingsWorkloadDiscoveryHelpContent, getAISettingsWorkloadDiscoverySummary, } from '@/utils/aiSettingsPresentation'; @@ -153,6 +156,23 @@ export const AIRuntimeControlsSection: Component +
+
+ + + state.setForm('shareOperationalContextWithCloud', event.currentTarget.checked) + } + disabled={state.saving()} + /> +
+

{getAISettingsCloudContextSharingSummary().text}

+
+
{ expect(aiRuntimeControlsSectionSource).not.toContain("fetch('/api/discovery/run"); }); + it('keeps the cloud operational-context opt-in wired through the canonical settings state', () => { + // The "Share operational context with cloud models" toggle must bind to the + // canonical AI settings form/store and round-trip the API field rather than + // reaching for a bespoke fetch or local-only flag. + expect(aiRuntimeControlsSectionSource).toContain('AI_SETTINGS_CLOUD_CONTEXT_SHARING_LABEL'); + expect(aiRuntimeControlsSectionSource).toContain('getAISettingsCloudContextSharingHelpContent'); + expect(aiRuntimeControlsSectionSource).toContain('state.form.shareOperationalContextWithCloud'); + expect(aiRuntimeControlsSectionSource).toContain( + "state.setForm('shareOperationalContextWithCloud', event.currentTarget.checked)", + ); + expect(aiSettingsStateSource).toContain('shareOperationalContextWithCloud'); + expect(aiSettingsStateSource).toContain( + 'payload.share_operational_context_with_cloud = form.shareOperationalContextWithCloud;', + ); + expect(aiSettingsStateSource).toContain( + 'shareOperationalContextWithCloud: data.share_operational_context_with_cloud ?? false,', + ); + }); + it('hydrates the Patrol preflight panel from the cached settings snapshot', () => { // The cached preflight outcome arrives on /api/settings/ai as // patrol_preflight; loadSettings and updateSettings must project it diff --git a/frontend-modern/src/components/Settings/useAISettingsState.ts b/frontend-modern/src/components/Settings/useAISettingsState.ts index 28b3c4c62..b1effefdb 100644 --- a/frontend-modern/src/components/Settings/useAISettingsState.ts +++ b/frontend-modern/src/components/Settings/useAISettingsState.ts @@ -303,6 +303,7 @@ export const useAISettingsState = () => { protectedGuests: '' as string, discoveryEnabled: false, discoveryIntervalHours: 0, + shareOperationalContextWithCloud: false, }); const showUpgradePrompts = () => !presentationPolicyHidesUpgradePrompts(); @@ -378,6 +379,7 @@ export const useAISettingsState = () => { protectedGuests: '', discoveryEnabled: false, discoveryIntervalHours: 0, + shareOperationalContextWithCloud: false, }); return; } @@ -410,6 +412,7 @@ export const useAISettingsState = () => { protectedGuests: Array.isArray(data.protected_guests) ? data.protected_guests.join(', ') : '', discoveryEnabled: data.discovery_enabled ?? false, discoveryIntervalHours: data.discovery_interval_hours ?? 0, + shareOperationalContextWithCloud: data.share_operational_context_with_cloud ?? false, }); const configured = new Set(); @@ -881,6 +884,7 @@ export const useAISettingsState = () => { payload.discovery_enabled = form.discoveryEnabled; payload.discovery_interval_hours = form.discoveryIntervalHours; + payload.share_operational_context_with_cloud = form.shareOperationalContextWithCloud; const updated = await AIAPI.updateSettings(payload); setSettings(updated); diff --git a/frontend-modern/src/types/ai.ts b/frontend-modern/src/types/ai.ts index 2840c3225..019c9b8a7 100644 --- a/frontend-modern/src/types/ai.ts +++ b/frontend-modern/src/types/ai.ts @@ -82,6 +82,10 @@ export interface AISettings { discovery_enabled?: boolean; discovery_interval_hours?: number; + // Cloud operational-context sharing - when true, PII-free operational + // context for governed resources is shared with cloud models. + share_operational_context_with_cloud?: boolean; + // Current Pulse Patrol runtime readiness for this settings snapshot patrol_readiness?: PatrolReadiness; // Most recent Patrol tool-call preflight result, recorded by Pulse so @@ -155,6 +159,10 @@ export interface AISettingsUpdateRequest { // AI Discovery settings discovery_enabled?: boolean; discovery_interval_hours?: number; + + // Cloud operational-context sharing - opt in to share PII-free operational + // context for governed resources with cloud models. + share_operational_context_with_cloud?: boolean; } export interface AITestResult { diff --git a/frontend-modern/src/utils/__tests__/aiSettingsPresentation.test.ts b/frontend-modern/src/utils/__tests__/aiSettingsPresentation.test.ts index 57aeaab50..49a91ff3b 100644 --- a/frontend-modern/src/utils/__tests__/aiSettingsPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/aiSettingsPresentation.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest'; import { AI_SETTINGS_ASSISTANT_PERMISSIONS_TITLE, AI_SETTINGS_ASSISTANT_SESSIONS_TITLE, + AI_SETTINGS_CLOUD_CONTEXT_SHARING_LABEL, AI_SETTINGS_MODEL_OVERRIDES_TITLE, AI_SETTINGS_PANEL_DESCRIPTION, AI_SETTINGS_PANEL_TITLE, getAICredentialsClearErrorMessage, + getAISettingsCloudContextSharingHelpContent, + getAISettingsCloudContextSharingSummary, getAIOAuthErrorMessage, getAIChatSessionsEmptyState, getAIChatSessionsLoadErrorMessage, @@ -41,6 +44,17 @@ describe('aiSettingsPresentation', () => { expect(getAISettingsWorkloadDiscoverySummary()).toEqual({ text: 'Workload discovery stores concrete service context for Assistant chat and Patrol verification, so responses and findings can reference real services and commands instead of generic advice.', }); + expect(AI_SETTINGS_CLOUD_CONTEXT_SHARING_LABEL).toBe( + 'Share operational context with cloud models', + ); + expect(getAISettingsCloudContextSharingHelpContent()).toEqual({ + title: 'Sharing operational context with cloud models', + description: + 'When on, Pulse shares PII-free operational context — access commands, config/data/log paths, and port numbers for discovered services — with cloud models (Anthropic, OpenAI, etc.) so the Assistant can give resource-specific guidance instead of generic advice. Identifying fields (hostnames, IP addresses, aliases, platform IDs) always stay redacted. Default off. Local Ollama models always receive full context regardless of this setting.', + }); + expect(getAISettingsCloudContextSharingSummary()).toEqual({ + text: 'Off by default: cloud models receive a terse redacted summary, so Assistant answers stay generic on cloud routes. Turning this on shares cloud-safe operational details (commands, paths, ports) while hostnames, IPs, and aliases remain redacted.', + }); expect(getAISettingsSetupDialogPresentation()).toEqual({ ariaLabel: 'Set up Assistant and Patrol', title: 'Set Up Assistant & Patrol', diff --git a/frontend-modern/src/utils/aiSettingsPresentation.ts b/frontend-modern/src/utils/aiSettingsPresentation.ts index 4f01d64a9..a7b79da50 100644 --- a/frontend-modern/src/utils/aiSettingsPresentation.ts +++ b/frontend-modern/src/utils/aiSettingsPresentation.ts @@ -54,6 +54,23 @@ export function getAISettingsWorkloadDiscoverySummary() { } as const; } +export const AI_SETTINGS_CLOUD_CONTEXT_SHARING_LABEL = + 'Share operational context with cloud models'; + +export function getAISettingsCloudContextSharingHelpContent() { + return { + title: 'Sharing operational context with cloud models', + description: + 'When on, Pulse shares PII-free operational context — access commands, config/data/log paths, and port numbers for discovered services — with cloud models (Anthropic, OpenAI, etc.) so the Assistant can give resource-specific guidance instead of generic advice. Identifying fields (hostnames, IP addresses, aliases, platform IDs) always stay redacted. Default off. Local Ollama models always receive full context regardless of this setting.', + } as const; +} + +export function getAISettingsCloudContextSharingSummary() { + return { + text: 'Off by default: cloud models receive a terse redacted summary, so Assistant answers stay generic on cloud routes. Turning this on shares cloud-safe operational details (commands, paths, ports) while hostnames, IPs, and aliases remain redacted.', + } as const; +} + export function getAISettingsSetupDialogPresentation(): AISettingsSetupDialogPresentation { return { ariaLabel: 'Set up Assistant and Patrol', diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 3ead012ba..33a7db789 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -2302,6 +2302,12 @@ type AISettingsResponse struct { // Discovery settings DiscoveryEnabled bool `json:"discovery_enabled"` // true if discovery is enabled DiscoveryIntervalHours int `json:"discovery_interval_hours,omitempty"` // Hours between auto-scans (0 = manual only) + // Cloud operational-context sharing - when true, PII-free operational + // context (access commands, config/data/log paths, ports) for governed + // resources is shared with cloud models so the Assistant can give + // resource-specific guidance. Identifying fields stay redacted; local + // (Ollama) models always receive full context regardless of this flag. + ShareOperationalContextWithCloud bool `json:"share_operational_context_with_cloud"` // Current Patrol runtime readiness after this settings snapshot is applied. PatrolReadiness *PatrolReadinessResponse `json:"patrol_readiness,omitempty"` // Most recent Patrol tool-call preflight outcome, surfaced so the UI @@ -2398,6 +2404,9 @@ type AISettingsUpdateRequest struct { // Discovery settings DiscoveryEnabled *bool `json:"discovery_enabled,omitempty"` // Enable discovery DiscoveryIntervalHours *int `json:"discovery_interval_hours,omitempty"` // Hours between auto-scans (0 = manual only) + // Cloud operational-context sharing - opt in to share PII-free operational + // context for governed resources with cloud models (nil = don't update). + ShareOperationalContextWithCloud *bool `json:"share_operational_context_with_cloud,omitempty"` } // AssistantEnabled reports whether the Pulse Assistant affordance should be @@ -2507,26 +2516,27 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R UseProactiveThresholds: settings.UseProactiveThresholds, AvailableModels: nil, // Now populated via /api/ai/models endpoint // Multi-provider configuration - AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic), - OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI), - OpenRouterConfigured: settings.HasProvider(config.AIProviderOpenRouter), - DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek), - GeminiConfigured: settings.HasProvider(config.AIProviderGemini), - OllamaConfigured: settings.HasProvider(config.AIProviderOllama), - OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama), - OllamaUsername: settings.OllamaUsername, - OllamaPasswordSet: settings.OllamaPassword != "", - OllamaKeepAlive: settings.GetOllamaKeepAlive(), - OpenAIBaseURL: settings.OpenAIBaseURL, - ConfiguredProviders: settings.GetConfiguredProviders(), - CostBudgetUSD30d: settings.CostBudgetUSD30d, - RequestTimeoutSeconds: settings.RequestTimeoutSeconds, - ControlLevel: settings.GetEffectiveControlLevel(hasAutoFixFeature), - ProtectedGuests: settings.GetProtectedGuests(), - DiscoveryEnabled: settings.IsDiscoveryEnabled(), - DiscoveryIntervalHours: settings.DiscoveryIntervalHours, - PatrolPreflight: cachedPatrolPreflightSnapshot(aiService), - PatrolReadiness: ptrToPatrolReadiness(h.buildPatrolReadiness(ctx, aiService, h.getPatrolService(ctx) != nil)), + AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic), + OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI), + OpenRouterConfigured: settings.HasProvider(config.AIProviderOpenRouter), + DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek), + GeminiConfigured: settings.HasProvider(config.AIProviderGemini), + OllamaConfigured: settings.HasProvider(config.AIProviderOllama), + OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama), + OllamaUsername: settings.OllamaUsername, + OllamaPasswordSet: settings.OllamaPassword != "", + OllamaKeepAlive: settings.GetOllamaKeepAlive(), + OpenAIBaseURL: settings.OpenAIBaseURL, + ConfiguredProviders: settings.GetConfiguredProviders(), + CostBudgetUSD30d: settings.CostBudgetUSD30d, + RequestTimeoutSeconds: settings.RequestTimeoutSeconds, + ControlLevel: settings.GetEffectiveControlLevel(hasAutoFixFeature), + ProtectedGuests: settings.GetProtectedGuests(), + DiscoveryEnabled: settings.IsDiscoveryEnabled(), + DiscoveryIntervalHours: settings.DiscoveryIntervalHours, + ShareOperationalContextWithCloud: settings.ShouldShareOperationalContextWithCloud(), + PatrolPreflight: cachedPatrolPreflightSnapshot(aiService), + PatrolReadiness: ptrToPatrolReadiness(h.buildPatrolReadiness(ctx, aiService, h.getPatrolService(ctx) != nil)), }.NormalizeCollections() if err := utils.WriteJSONResponse(w, response); err != nil { @@ -2850,6 +2860,11 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt settings.DiscoveryIntervalHours = 24 } + // Handle cloud operational-context sharing (nil = don't update) + if req.ShareOperationalContextWithCloud != nil { + settings.ShareOperationalContextWithCloud = *req.ShareOperationalContextWithCloud + } + if aiSettingsRequireModelResolution(settings) { resolvedModel, resolveErr := ai.ResolveConfiguredModel(r.Context(), settings) if resolveErr != nil { @@ -2960,25 +2975,26 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt UseProactiveThresholds: settings.UseProactiveThresholds, AvailableModels: nil, // Now populated via /api/ai/models endpoint // Multi-provider configuration - AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic), - OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI), - OpenRouterConfigured: settings.HasProvider(config.AIProviderOpenRouter), - DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek), - GeminiConfigured: settings.HasProvider(config.AIProviderGemini), - OllamaConfigured: settings.HasProvider(config.AIProviderOllama), - OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama), - OllamaUsername: settings.OllamaUsername, - OllamaPasswordSet: settings.OllamaPassword != "", - OllamaKeepAlive: settings.GetOllamaKeepAlive(), - OpenAIBaseURL: settings.OpenAIBaseURL, - ConfiguredProviders: settings.GetConfiguredProviders(), - RequestTimeoutSeconds: settings.RequestTimeoutSeconds, - ControlLevel: settings.GetEffectiveControlLevel(hasAutoFixFeature), - ProtectedGuests: settings.GetProtectedGuests(), - DiscoveryEnabled: settings.DiscoveryEnabled, - DiscoveryIntervalHours: settings.DiscoveryIntervalHours, - PatrolReadiness: ptrToPatrolReadiness(patrolReadiness), - PatrolPreflight: cachedPatrolPreflightSnapshot(aiService), + AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic), + OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI), + OpenRouterConfigured: settings.HasProvider(config.AIProviderOpenRouter), + DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek), + GeminiConfigured: settings.HasProvider(config.AIProviderGemini), + OllamaConfigured: settings.HasProvider(config.AIProviderOllama), + OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama), + OllamaUsername: settings.OllamaUsername, + OllamaPasswordSet: settings.OllamaPassword != "", + OllamaKeepAlive: settings.GetOllamaKeepAlive(), + OpenAIBaseURL: settings.OpenAIBaseURL, + ConfiguredProviders: settings.GetConfiguredProviders(), + RequestTimeoutSeconds: settings.RequestTimeoutSeconds, + ControlLevel: settings.GetEffectiveControlLevel(hasAutoFixFeature), + ProtectedGuests: settings.GetProtectedGuests(), + DiscoveryEnabled: settings.DiscoveryEnabled, + DiscoveryIntervalHours: settings.DiscoveryIntervalHours, + ShareOperationalContextWithCloud: settings.ShouldShareOperationalContextWithCloud(), + PatrolReadiness: ptrToPatrolReadiness(patrolReadiness), + PatrolPreflight: cachedPatrolPreflightSnapshot(aiService), }.NormalizeCollections() if err := utils.WriteJSONResponse(w, response); err != nil { diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index c6fb6c6da..146ff0dca 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -494,6 +494,89 @@ func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) { } } +func TestAISettingsHandler_ShareOperationalContextWithCloud_RoundTrip(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + + handler := newTestAISettingsHandler(cfg, persistence, nil) + + getShareFlag := func(t *testing.T) bool { + t.Helper() + req := newLoopbackRequest(http.MethodGet, "/api/settings/ai", nil) + rec := httptest.NewRecorder() + handler.HandleGetAISettings(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET status = %d, body=%s", rec.Code, rec.Body.String()) + } + // The field is always serialized (no omitempty) so the operator UI can + // bind a toggle to its concrete value. + if !strings.Contains(rec.Body.String(), `"share_operational_context_with_cloud":`) { + t.Fatalf("expected share_operational_context_with_cloud in GET body, got %s", rec.Body.String()) + } + var resp AISettingsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + return resp.ShareOperationalContextWithCloud + } + + updateShareFlag := func(t *testing.T, req AISettingsUpdateRequest) AISettingsResponse { + t.Helper() + body, _ := json.Marshal(req) + httpReq := newLoopbackRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.HandleUpdateAISettings(rec, httpReq) + if rec.Code != http.StatusOK { + t.Fatalf("PUT status = %d, body=%s", rec.Code, rec.Body.String()) + } + var resp AISettingsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + return resp + } + + // Default off before any save. + if getShareFlag(t) { + t.Fatalf("expected default share_operational_context_with_cloud=false") + } + + // Enabling AI alongside the opt-in must round-trip the flag as true. + resp := updateShareFlag(t, AISettingsUpdateRequest{ + Enabled: ptr(true), + Model: ptr("ollama:llama3"), + OllamaBaseURL: ptr("http://localhost:11434"), + ShareOperationalContextWithCloud: ptr(true), + }) + if !resp.ShareOperationalContextWithCloud { + t.Fatalf("expected PUT response share_operational_context_with_cloud=true, got %+v", resp) + } + if !getShareFlag(t) { + t.Fatalf("expected persisted share_operational_context_with_cloud=true after enabling") + } + + // Omitting the field (nil pointer) must leave the persisted opt-in untouched. + resp = updateShareFlag(t, AISettingsUpdateRequest{Model: ptr("ollama:llama3")}) + if !resp.ShareOperationalContextWithCloud { + t.Fatalf("expected omitted field to preserve opt-in, got %+v", resp) + } + if !getShareFlag(t) { + t.Fatalf("expected persisted opt-in to survive an unrelated save") + } + + // Explicit false turns the opt-in back off. + resp = updateShareFlag(t, AISettingsUpdateRequest{ShareOperationalContextWithCloud: ptr(false)}) + if resp.ShareOperationalContextWithCloud { + t.Fatalf("expected PUT response share_operational_context_with_cloud=false, got %+v", resp) + } + if getShareFlag(t) { + t.Fatalf("expected persisted share_operational_context_with_cloud=false after opt-out") + } +} + func TestAISettingsHandler_GetSettingsClampsPaidControlsToEntitlements(t *testing.T) { t.Parallel() diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 92372ae67..6e95df6d4 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -1335,6 +1335,7 @@ func TestContract_AISettingsUpdateProviderResolutionJSONSnapshot(t *testing.T) { "control_level":"read_only", "protected_guests":[], "discovery_enabled":false, + "share_operational_context_with_cloud":false, "patrol_readiness":{ "status":"warning", "ready":true, @@ -1479,6 +1480,7 @@ func TestContract_AISettingsBYOKOverrideDoesNotExposeQuickstartInventoryJSONSnap "control_level":"read_only", "protected_guests":[], "discovery_enabled":false, + "share_operational_context_with_cloud":false, "patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]} }` @@ -3484,6 +3486,7 @@ func TestContract_HostedAISettingsDoesNotAutoBootstrapQuickstartJSONSnapshot(t * "control_level":"read_only", "protected_guests":[], "discovery_enabled":false, + "share_operational_context_with_cloud":false, "patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]} }` @@ -3547,6 +3550,7 @@ func TestContract_AISettingsRetiredQuickstartAliasJSONSnapshot(t *testing.T) { "control_level":"read_only", "protected_guests":[], "discovery_enabled":false, + "share_operational_context_with_cloud":false, "patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]} }` @@ -3615,6 +3619,7 @@ func TestContract_AISettingsOllamaAuthJSONSnapshot(t *testing.T) { "control_level":"read_only", "protected_guests":[], "discovery_enabled":false, + "share_operational_context_with_cloud":false, "patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]} }` @@ -4176,6 +4181,7 @@ func TestContract_HostedTenantAISettingsDoesNotAutoBootstrapQuickstartJSONSnapsh "control_level":"read_only", "protected_guests":[], "discovery_enabled":false, + "share_operational_context_with_cloud":false, "patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]} }`