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.
This commit is contained in:
rcourtman
2026-06-08 10:05:37 +01:00
parent 32d5972673
commit 3f76da7932
14 changed files with 271 additions and 39 deletions
@@ -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`
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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<AIRuntimeControlsSectionProps>
</Show>
</div>
<div class="space-y-2 p-3 rounded-md border border-border bg-surface-alt">
<div class="flex items-center justify-between gap-2">
<label class="text-xs font-medium text-base-content flex items-center gap-1.5">
{AI_SETTINGS_CLOUD_CONTEXT_SHARING_LABEL}
<HelpIcon inline={getAISettingsCloudContextSharingHelpContent()} size="xs" />
</label>
<Toggle
checked={state.form.shareOperationalContextWithCloud}
onChange={(event) =>
state.setForm('shareOperationalContextWithCloud', event.currentTarget.checked)
}
disabled={state.saving()}
/>
</div>
<p class="text-[10px] text-muted">{getAISettingsCloudContextSharingSummary().text}</p>
</div>
<div class="flex items-center gap-3 p-3 rounded-md border border-border bg-surface-alt">
<svg
class="w-4 h-4 text-muted flex-shrink-0"
@@ -379,6 +379,25 @@ describe('settings architecture guardrails', () => {
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
@@ -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<AIProvider>();
@@ -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);
+8
View File
@@ -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 {
@@ -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',
@@ -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',
+55 -39
View File
@@ -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 {
+83
View File
@@ -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()
+6
View File
@@ -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"}]}
}`