mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"}]}
|
||||
}`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user