Fix late issue triage regressions

Refs #1341

Refs #1429

Refs #1469

Refs #1472

Refs #1476

Refs #1481
This commit is contained in:
rcourtman 2026-05-28 14:49:20 +01:00
parent 47e9eb9aea
commit 7297aae4af
40 changed files with 1026 additions and 146 deletions

View file

@ -0,0 +1,78 @@
# Known RC Issue Closure For GA Late Issue Integration Record
- Date: `2026-05-28`
- Gate: `known-rc-issue-closure-for-ga`
- Issues: `#1341`, `#1429`, `#1469`, `#1472`, `#1476`, `#1481`
- Result: `fixed-local-proof`
## Context
The May 28 open-issue pass found several late reports where the right v6
outcome was not a v5-only patch or public issue state change:
- `#1476` exposed a PBS setup-script auto-registration 405 after token
creation.
- `#1472` exposed an OIDC refresh cadence defect where five-minute access
tokens were treated as immediately refreshable.
- `#1481` requested a configurable Ollama `keep_alive` runtime option.
- `#1429` remained open after v6 navigation clarification and agent reinstall
feedback; the remaining v6-hardening gap was host-agent continuity across
short/FQDN hostname drift after upgrade or reload.
- `#1469` was checked against the current branch. TrueNAS decode fixes and
unified connection runtime summaries were already present; a regression test
now locks the expected connection-ledger presentation.
- `#1341` received fresh reporter evidence showing a saved Ceph pool override
at 50 percent while the agent-reported pool was above threshold and no active
alert fired. Screenshots were inspected before treating the new evidence as
actionable.
## Disposition
The integrated v6 fixes are:
- PBS setup scripts now post auto-registration to the Pulse base URL plus
`/api/auto-register`, not the setup-script download URL plus that path.
- OIDC session refresh now uses a relative refresh lead based on the access
token lifetime, capped at five minutes, with a one-minute fallback for
legacy sessions that do not yet carry issued-at metadata.
- OIDC sessions persist access-token issued-at metadata and update it after a
successful refresh.
- Ollama provider settings now expose and persist `ollama_keep_alive`; the
provider sends that value as Ollama `keep_alive`, preserves numeric values
such as `0` and `-1`, and omits the field when the operator clears it to use
the Ollama server default.
- Host-agent continuity now treats short and fully-qualified hostnames as
equivalent where one side is a short name and the other side is its FQDN, and
host-token bindings alias the current hostname form after reload.
- Agent-sourced Ceph pool reports now run pool alert evaluation, reconcile
with Proxmox API reports by FSID, and preserve old `agent:<host>` pool IDs
as threshold aliases while emitting alerts under the normalized storage ID.
- TrueNAS connection-ledger regression coverage confirms successful runtime
summaries do not present as `Credentials unknown` or `No activity yet`.
This record does not close any public GitHub issue by itself. Public issue
closure should still wait for normal maintainer review, release publication,
or reporter retest where the issue thread needs environment confirmation.
## Proof
- `go test ./internal/api -run 'TestPBSSetupScript_FailsClosedOnAutoRegisterSuccessDetection|TestHandleSetupScript_PBSWithAuthToken|TestHandleSetupScript_PBSTypeGeneratesScript|TestHandleCanonicalAutoRegister_PBS' -count=1`
- `go test ./internal/api -run 'Test(ShouldRefreshOIDCSessionToken|RefreshOIDCSessionTokens|CreateOIDCSession|UpdateOIDCTokens|OIDCSessionPersistence|CreateOIDCSession_NilTokenInfo|InvalidateSession|SessionStore)' -count=1`
- `go test ./internal/api -run 'TestAISettingsHandler_(UpdateSettings_OllamaKeepAlive|UpdateSettingsRejectsInvalidOllamaKeepAlive|GetAndUpdateSettings_RoundTrip)|TestContract_.*AI' -count=1`
- `go test ./internal/ai/providers ./internal/config`
- `go test ./internal/alerts -run 'Test(StorageThresholdResolutionUsesAliasIDs|ReevaluateActiveAlertsUsesSharedStorageOverrideResolution|CheckStorageOfflineUsesSharedThresholdResolution)' -count=1`
- `go test ./internal/models -run 'Test(StateUpsertCephCluster|UpdateCephClustersForInstance)' -count=1`
- `go test ./internal/monitoring -run 'TestApplyHostReport(FiresCephPoolAlertsForAgentSourcedCluster|MergesAgentCephWithProxmoxAPICluster|ReusesTokenBindingAcrossShortFQDNAfterReload)|TestRemoveHostAgentUnbindsToken|TestPollCephClusterChecksPoolStorageThresholds' -count=1`
- `npm --prefix frontend-modern test -- src/components/Settings/__tests__/AISettings.test.tsx src/components/Settings/__tests__/useConnectionsLedger.test.ts src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts`
- `npm --prefix frontend-modern run type-check`
- Browser inspection of Settings > Assistant & Patrol provider configuration for the Ollama Keep Alive field.
## Remaining Boundaries
- `#1482` is treated as a v5/configuration-specific Docker-agent-as-container
report, not as evidence that Docker-only installs should produce a host row.
- The storage-offline follow-up in `#1429` remains a separate storage
connection-state lane and was not resolved by the host continuity hardening.
- The OneDrive video linked from `#1341` was not inspected during this batch;
the attached screenshots and pasted `/data/alerts.json` override were enough
to validate the Ceph alert-evaluation gap addressed here.

View file

@ -6000,6 +6000,12 @@
"kind": "file",
"evidence_tier": "managed-runtime-exercise"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-late-issue-integration-2026-05-28.md",
"kind": "file",
"evidence_tier": "test-proof"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-metric-threshold-coloring-2026-05-01.md",

View file

@ -127,6 +127,11 @@ reverse.
19. `internal/api/agent_install_command_shared.go` shared with `api-contracts`: agent install command assembly is both an agent lifecycle control surface and a canonical API payload contract boundary.
20. `internal/api/config_setup_handlers.go` shared with `api-contracts`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
21. `internal/api/setup_script_render.go` shared with `api-contracts`, `storage-recovery`: the generated Proxmox setup-script is a shared boundary across agent lifecycle (forced-command keys, install/uninstall edits), API contracts (rendered token shape and encoded rerun URL), and storage/recovery (backup visibility grants, Pulse-managed temperature SSH keys, and SMART disk-temperature collection).
PBS setup-script auto-registration remains lifecycle-owned bootstrap
transport: rendered scripts must post registration payloads to the canonical
Pulse base URL plus `/api/auto-register`, not to the script download
artifact URL, so install-time registration keeps one API root regardless of
whether the script came from `/api/setup-script` or `/api/setup-script-url`.
22. `internal/api/unified_agent.go` shared with `api-contracts`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
23. `scripts/install.ps1` shared with `deployment-installability`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
The Windows installer must support a non-mutating download preflight that

View file

@ -55,7 +55,12 @@ runtime cost control, and shared AI transport surfaces.
## Extension Points
1. Add or change chat runtime, Patrol orchestration, findings generation, or remediation behavior through `internal/ai/`
2. Add or change canonical AI provider config, provider-scoped model selection, or runtime auth/base-URL defaults through `internal/config/ai.go`
2. Add or change canonical AI provider config, provider-scoped model selection, or runtime auth/base-URL defaults through `internal/config/ai.go`.
Ollama request keep-alive is a provider runtime option owned by this path:
`internal/config/ai.go` stores the value, `/api/settings/ai` exposes it,
and `internal/ai/providers/ollama.go` is the only layer that turns it into
the Ollama `keep_alive` request field. An empty configured value means
Pulse omits `keep_alive` so the Ollama server default applies.
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`
4. Add or change Patrol, alert-analysis, or remediation transport through `internal/api/ai_handlers.go`, `internal/api/ai_intelligence_handlers.go`, and `frontend-modern/src/api/patrol.ts`
Provider preflight diagnostics returned from `internal/api/ai_handlers.go`

View file

@ -1506,6 +1506,11 @@ the canonical monitored-system blocked payload.
backend contract must keep provider test routes bound to the selected
provider's configured model instead of whichever other provider currently
owns the default `model` field.
The Ollama provider payload also owns `ollama_keep_alive` as the canonical
request keep-alive field: GET and update responses must expose the
normalized configured value, update requests must reject malformed values,
an empty string means omit Ollama `keep_alive`, and stored provider secrets
remain masked independently of that runtime option.
Discovery scheduling is part of that same AI settings payload contract:
settings saves from `frontend-modern/src/components/Settings/useAISettingsState.ts`
must send `discovery_enabled` and `discovery_interval_hours` together as
@ -3879,6 +3884,10 @@ That same payload contract must also fail closed on auto-register transport:
the generated script must use fail-fast `curl -fsS` request transport and only
evaluate the response payload after a successful curl exit status, rather than
parsing ambiguous stderr or HTTP-failure output as a valid registration body.
For PBS setup scripts, the generated auto-register POST URL must be the
canonical Pulse base URL plus `/api/auto-register`. The script may not append
that path to the setup-script download artifact URL, because the artifact URL is
only for fetching the script and is not the API root.
That same setup-script payload must also preserve the canonical auth guidance:
authentication failures in the generated script text must reference the active
Pulse setup-token flow, not stale API-token setup instructions, because the
@ -4374,6 +4383,11 @@ from or saved to encrypted-at-rest session payloads, and the runtime must drop
them whenever session-store crypto is unavailable or the stored ciphertext is
not canonically decryptable instead of preserving plaintext-at-rest session
state.
OIDC access-token issued-at metadata is part of the same session persistence
contract. Short-lived access tokens must persist `oidc_access_token_issued_at`
beside `oidc_access_token_exp` so refresh scheduling can use a relative
lifetime window after restart instead of treating every five-minute token as
immediately refreshable.
Hosted signup handler payload flow now also follows an explicit shared
boundary: `internal/api/public_signup_handlers.go` owns request/response and
magic-link payload semantics, while `internal/hosted/provisioner.go` owns the

View file

@ -1424,6 +1424,12 @@ not a replacement status card, CTA band, or page-local nested card.
## Current State
AI settings provider fields are a governed frontend primitive, not a
provider-local form fork. The shared provider configuration section must render
provider-specific controls from `aiSettingsModel.ts` `extraFields`, including
Ollama `keep_alive`, so Assistant and Patrol keep one settings shape across
labeling, help affordances, helper copy, and persistence binding.
Kubernetes RBAC inventory (Roles, ClusterRoles, RoleBindings,
ClusterRoleBindings) is part of the existing Kubernetes platform-page
Configuration tab, not a new sidebar entry or top-level route, and the
@ -3130,6 +3136,11 @@ maintenance routes through `AIChatMaintenanceSection.tsx`, and readiness plus
save/test actions route through `AISettingsStatusAndActions.tsx`. Future AI
settings work must extend those section owners instead of re-inlining large
runtime subsections into the shell.
Provider-specific settings fields inside
`AIProviderConfigurationSection.tsx` must remain model-driven through
`aiSettingsModel.ts` `extraFields`, including Ollama `keep_alive`, so the
shared provider panel owns framing, labels, help affordances, helper copy, and
form binding instead of adding provider-local bespoke controls.
That same AI settings boundary now also owns
`frontend-modern/src/utils/aiSettingsPresentation.ts`, so shared loading,
empty, OAuth, action/error, shell-description, and workload-discovery copy

View file

@ -290,6 +290,12 @@ This subsystem now sits under the dedicated core monitoring runtime lane so
discovery, metrics-history correctness, and platform-specific runtime coverage
can be governed as first-class product work instead of staying diluted inside
architecture coherence.
Standalone host-agent identity continuity is part of that monitoring runtime
contract. `internal/monitoring/monitor_agents.go` must resolve short/FQDN
hostname aliases through the shared unified-resource equivalence rule when it
binds tokens, matches reports, and removes ignored agents, so reconnects and
reloads keep the same canonical host without weakening token uniqueness across
different machines.
That same monitoring boundary now owns agentless availability targets as a
first-class provider, not as a settings-only helper. Saved availability targets
load from the config persistence boundary, schedule through

View file

@ -69,6 +69,11 @@ auto-registration changes that affect backup visibility permissions are
storage/recovery-adjacent: optional PVE `/storage` grants must remain effective
for privilege-separated tokens by assigning the same `PVEDatastoreAdmin` role to
both the service user and the concrete token id.
PBS generated setup-script auto-registration is storage/recovery-adjacent
because the registration result determines whether PBS backup evidence can flow
without manual follow-up. The rendered script must post auto-registration to the
canonical Pulse base URL plus `/api/auto-register`; it must not derive the POST
target from the setup-script artifact download URL.
Proxmox platform backup surfaces may embed source-specific backup evidence, but
the source of truth must stay explicit. PBS source-detail tables on the Proxmox

View file

@ -158,30 +158,6 @@ export const AIProviderConfigurationSection: Component<AIProviderConfigurationSe
</button>
<Show when={expanded()}>
<div class="px-3 py-3 bg-surface-alt border-t border-border space-y-2">
<Show when={config.extraField}>
{(extraField) => (
<div class="space-y-1">
<label class="text-xs text-muted inline-flex items-center gap-1">
{extraField().label}
<Show when={extraField().helpContentId}>
<HelpIcon contentId={extraField().helpContentId!} size="xs" />
</Show>
</label>
<input
type="url"
value={props.form[extraField().inputField]}
onInput={(event) =>
props.setForm(extraField().inputField, event.currentTarget.value)
}
placeholder={extraField().placeholder}
aria-label={`${getAIProviderDisplayName(config.provider)} ${extraField().label}`}
class={controlClass()}
disabled={props.saving()}
/>
</div>
)}
</Show>
<Show when={config.provider === 'ollama'}>
<label class="text-xs text-muted inline-flex items-center gap-1">
Server URL
@ -206,6 +182,33 @@ export const AIProviderConfigurationSection: Component<AIProviderConfigurationSe
disabled={props.saving()}
/>
<For each={config.extraFields || []}>
{(extraField) => (
<div class="space-y-1">
<label class="text-xs text-muted inline-flex items-center gap-1">
{extraField.label}
<Show when={extraField.helpContentId}>
<HelpIcon contentId={extraField.helpContentId!} size="xs" />
</Show>
</label>
<input
type={extraField.type || 'text'}
value={props.form[extraField.inputField]}
onInput={(event) =>
props.setForm(extraField.inputField, event.currentTarget.value)
}
placeholder={extraField.placeholder}
aria-label={`${getAIProviderDisplayName(config.provider)} ${extraField.label}`}
class={controlClass()}
disabled={props.saving()}
/>
<Show when={extraField.helperText}>
<p class="text-xs text-muted">{extraField.helperText}</p>
</Show>
</div>
)}
</For>
<Show when={config.helperText}>
<p class="text-xs text-muted">{config.helperText}</p>
</Show>

View file

@ -100,6 +100,7 @@ const baseSettings = (): AISettingsType => ({
gemini_configured: false,
ollama_configured: false,
ollama_base_url: 'http://localhost:11434',
ollama_keep_alive: '30s',
configured_providers: [],
});
@ -635,6 +636,53 @@ describe('AISettings OpenRouter flow', () => {
});
});
describe('AISettings Ollama provider options', () => {
beforeEach(() => {
resetAllMocks();
setupDefaultMocks();
});
afterEach(() => {
cleanup();
});
it('saves Ollama keep alive through the provider settings panel', async () => {
getSettingsMock.mockResolvedValue({
...baseSettings(),
configured: true,
model: 'ollama:llama3',
ollama_configured: true,
configured_providers: ['ollama'],
ollama_keep_alive: '30s',
});
updateSettingsMock.mockImplementation(async (payload: Record<string, unknown>) => ({
...baseSettings(),
configured: true,
model: 'ollama:llama3',
ollama_configured: true,
configured_providers: ['ollama'],
ollama_base_url: 'http://localhost:11434',
ollama_keep_alive: (payload.ollama_keep_alive as string) ?? '30s',
}));
renderComponent();
fireEvent.input(await screen.findByLabelText('Ollama Keep Alive'), {
target: { value: '24h' },
});
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() => {
expect(updateSettingsMock).toHaveBeenCalledWith(
expect.objectContaining({
model: 'ollama:llama3',
ollama_keep_alive: '24h',
}),
);
});
});
});
describe('AISettings provider save failure context', () => {
beforeEach(() => {
resetAllMocks();

View file

@ -5,6 +5,7 @@ import settingsPageShellSource from '../SettingsPageShell.tsx?raw';
import aiSettingsDialogsSource from '../AISettingsDialogs.tsx?raw';
import aiChatMaintenanceSectionSource from '../AIChatMaintenanceSection.tsx?raw';
import aiModelSelectionSectionSource from '../AIModelSelectionSection.tsx?raw';
import aiProviderConfigurationSectionSource from '../AIProviderConfigurationSection.tsx?raw';
import aiRuntimeControlsSectionSource from '../AIRuntimeControlsSection.tsx?raw';
import aiSettingsModelSource from '../aiSettingsModel.ts?raw';
import generalSettingsPanelSource from '../GeneralSettingsPanel.tsx?raw';
@ -302,6 +303,18 @@ describe('settings architecture guardrails', () => {
expect(aiSettingsStateSource).not.toContain('OpenRouter returned 401');
});
it('keeps Assistant and Patrol provider-specific fields model-driven', () => {
expect(aiSettingsModelSource).toContain('extraFields: [');
expect(aiSettingsModelSource).toContain("inputField: 'ollamaKeepAlive'");
expect(aiSettingsModelSource).toContain("helpContentId: 'ai.ollama.keepAlive'");
expect(aiProviderConfigurationSectionSource).toContain('<For each={config.extraFields || []}>');
expect(aiProviderConfigurationSectionSource).toContain(
'aria-label={`${getAIProviderDisplayName(config.provider)} ${extraField.label}`}',
);
expect(aiProviderConfigurationSectionSource).not.toContain('<Show when={config.extraField}>');
expect(aiProviderConfigurationSectionSource).not.toContain('extraField()');
});
it('keeps Patrol tool-call preflight wired through the canonical settings state', () => {
// The Verify Patrol button must drive the canonical
// /api/ai/patrol/preflight endpoint via the typed runPatrolPreflight

View file

@ -83,6 +83,67 @@ describe('useConnectionsLedger', () => {
]);
});
it('presents successful TrueNAS runtime state as verified activity', async () => {
const lastSeen = '2026-05-28T12:00:00Z';
const connections: Connection[] = [
{
id: 'truenas:tn1',
type: 'truenas',
name: 'tower',
address: 'https://truenas.local:443',
state: 'active',
stateReason: '',
enabled: true,
surfaces: ['datasets', 'pools', 'replication'],
scope: { datasets: true, pools: true, replication: true },
lastSeen,
lastError: null,
source: 'manual',
fleet: {
enrollmentState: 'configured',
livenessState: 'active',
versionDrift: 'not-applicable',
adapterHealth: 'healthy',
configRollout: 'configured',
credentialStatus: 'verified',
updateStatus: 'not-applicable',
remoteControl: 'not-applicable',
configDrift: { status: 'current' },
rollout: { status: 'current' },
credentialHealth: { status: 'verified', kind: 'api-key', lastVerifiedAt: lastSeen },
commandPolicy: { status: 'not-applicable' },
},
capabilities: { supportsPause: true, supportsScope: true, supportsTest: true },
},
];
const systems: ConnectionSystem[] = [
{
id: 'truenas:tn1',
type: 'truenas',
components: [{ connectionId: 'truenas:tn1', type: 'truenas', role: 'primary' }],
},
];
vi.spyOn(ConnectionsAPI, 'list').mockResolvedValue({ connections, systems });
const { result } = renderHook(() => useConnectionsLedger());
await waitFor(() => expect(result.rows()).toHaveLength(1));
expect(result.rows()[0]).toMatchObject({
id: 'truenas:tn1',
ownerType: 'truenas',
name: 'tower',
source: 'api',
statusLabel: 'Active',
coverageLabels: ['Datasets', 'Pools', 'Replication'],
});
expect(result.rows()[0].lastActivityText).not.toBe('No activity yet');
expect(result.rows()[0].lastActivityText).not.toBe('Unknown');
expect(result.rows()[0].problem).toBeUndefined();
expect(result.rows()[0].fleetHighlights.map((signal) => signal.label)).not.toContain(
'Credentials unknown',
);
});
it('reuses large stable row models across equivalent ledger refreshes', async () => {
const connections: Connection[] = Array.from({ length: 120 }, (_, index) => ({
id: `agent:stable-${index}`,
@ -352,7 +413,8 @@ describe('useConnectionsLedger', () => {
desired: 'disabled',
applied: 'enabled',
enforcement: 'drifted',
reason: 'agent still reports command execution enabled while desired policy disables it',
reason:
'agent still reports command execution enabled while desired policy disables it',
},
},
capabilities: { supportsPause: false, supportsScope: false, supportsTest: false },
@ -505,7 +567,8 @@ describe('useConnectionsLedger', () => {
remoteControl: 'disabled',
configDrift: {
status: 'pending',
reason: 'Pulse has not received a comparable applied agent configuration fingerprint yet',
reason:
'Pulse has not received a comparable applied agent configuration fingerprint yet',
},
rollout: {
status: 'pending',
@ -545,7 +608,8 @@ describe('useConnectionsLedger', () => {
remoteControl: 'disabled',
configDrift: {
status: 'pending',
reason: 'Pulse has not received a comparable applied agent configuration fingerprint yet',
reason:
'Pulse has not received a comparable applied agent configuration fingerprint yet',
},
rollout: {
status: 'pending',

View file

@ -15,6 +15,7 @@ export type AIProviderCredentialsFormState = {
deepseekApiKey: string;
geminiApiKey: string;
ollamaBaseUrl: string;
ollamaKeepAlive: string;
openaiBaseUrl: string;
};
@ -30,12 +31,14 @@ export type AIProviderConfig = {
actionLinkHref: string;
actionLinkSuffix?: string;
helperText?: string;
extraField?: {
extraFields?: Array<{
label: string;
helpContentId?: string;
inputField: 'openaiBaseUrl';
inputField: keyof AIProviderCredentialsFormState;
placeholder: string;
};
type?: 'url' | 'text';
helperText?: string;
}>;
clearTitle: string;
};
@ -95,12 +98,15 @@ export const AI_PROVIDER_CONFIGS: AIProviderConfig[] = [
actionLinkLabel: 'Get API key →',
actionLinkHref: 'https://platform.openai.com/api-keys',
clearTitle: 'Clear API key',
extraField: {
label: 'Custom Base URL',
helpContentId: 'ai.openai.baseUrl',
inputField: 'openaiBaseUrl',
placeholder: 'https://api.together.xyz/v1 (optional)',
},
extraFields: [
{
label: 'Custom Base URL',
helpContentId: 'ai.openai.baseUrl',
inputField: 'openaiBaseUrl',
placeholder: 'https://api.together.xyz/v1 (optional)',
type: 'url',
},
],
},
{
provider: 'openrouter',
@ -149,6 +155,16 @@ export const AI_PROVIDER_CONFIGS: AIProviderConfig[] = [
actionLinkLabel: 'Learn about Ollama →',
actionLinkHref: 'https://ollama.ai',
actionLinkSuffix: ' · Free & local',
extraFields: [
{
label: 'Keep Alive',
helpContentId: 'ai.ollama.keepAlive',
inputField: 'ollamaKeepAlive',
placeholder: '30s, 5m, 24h, 0, or blank',
type: 'text',
helperText: 'Clear to use the Ollama server default.',
},
],
clearTitle: 'Clear Ollama URL',
},
];

View file

@ -52,7 +52,7 @@ const AI_SETTINGS_PROVIDER_PAYLOAD_FIELDS: Record<AIProvider, string[]> = {
openrouter: ['openrouter_api_key'],
deepseek: ['deepseek_api_key'],
gemini: ['gemini_api_key'],
ollama: ['ollama_base_url'],
ollama: ['ollama_base_url', 'ollama_keep_alive'],
};
const compactUnique = (values: Array<string | undefined>): string[] => {
@ -302,6 +302,7 @@ export const useAISettingsState = () => {
deepseekApiKey: '',
geminiApiKey: '',
ollamaBaseUrl: 'http://localhost:11434',
ollamaKeepAlive: '30s',
openaiBaseUrl: '',
costBudgetUSD30d: '',
requestTimeoutSeconds: 300,
@ -376,6 +377,7 @@ export const useAISettingsState = () => {
deepseekApiKey: '',
geminiApiKey: '',
ollamaBaseUrl: 'http://localhost:11434',
ollamaKeepAlive: '30s',
openaiBaseUrl: '',
costBudgetUSD30d: '',
requestTimeoutSeconds: 300,
@ -404,6 +406,7 @@ export const useAISettingsState = () => {
deepseekApiKey: '',
geminiApiKey: '',
ollamaBaseUrl: data.ollama_base_url || 'http://localhost:11434',
ollamaKeepAlive: data.ollama_keep_alive ?? '30s',
openaiBaseUrl: data.openai_base_url || '',
costBudgetUSD30d:
typeof data.cost_budget_usd_30d === 'number' && data.cost_budget_usd_30d > 0
@ -721,7 +724,13 @@ export const useAISettingsState = () => {
error,
payload,
providerHealth,
models: [form.model, form.chatModel, form.patrolModel, form.discoveryModel, form.autoFixModel],
models: [
form.model,
form.chatModel,
form.patrolModel,
form.discoveryModel,
form.autoFixModel,
],
});
notificationStore.error(
getAISettingsSaveProviderFailureMessage(
@ -897,6 +906,9 @@ export const useAISettingsState = () => {
) {
payload.ollama_base_url = form.ollamaBaseUrl.trim();
}
if (form.ollamaKeepAlive.trim() !== (settings()?.ollama_keep_alive ?? '30s')) {
payload.ollama_keep_alive = form.ollamaKeepAlive.trim();
}
if (form.openaiBaseUrl !== (settings()?.openai_base_url || '')) {
payload.openai_base_url = form.openaiBaseUrl.trim();
}
@ -954,7 +966,13 @@ export const useAISettingsState = () => {
error,
payload,
providerHealth,
models: [selectedModel, form.chatModel, form.patrolModel, form.discoveryModel, form.autoFixModel],
models: [
selectedModel,
form.chatModel,
form.patrolModel,
form.discoveryModel,
form.autoFixModel,
],
});
notificationStore.error(
getAISettingsSaveProviderFailureMessage(

View file

@ -47,7 +47,17 @@ export const aiHelpContent: HelpContent[] = [
'- Alert investigation assistance\n\n' +
'You can configure multiple providers and Pulse will use the primary provider ' +
'with fallback to others if unavailable.',
related: ['ai.openai.baseUrl', 'ai.ollama.baseUrl'],
related: ['ai.openai.baseUrl', 'ai.ollama.baseUrl', 'ai.ollama.keepAlive'],
addedInVersion: 'v4.0.0',
},
{
id: 'ai.ollama.keepAlive',
title: 'Ollama Keep Alive',
description:
'Controls the keep_alive value Pulse sends with Ollama chat requests.\n\n' +
'Use a duration such as 30s, 5m, or 24h. Use 0 to unload immediately after a request, or -1 to keep the model loaded indefinitely. Leave the field blank to omit keep_alive so the Ollama server default applies.',
examples: ['30s (Pulse default)', '24h (keep warm for a day)', 'blank (server default)'],
related: ['ai.ollama.baseUrl'],
addedInVersion: 'v6.0.0',
},
];

View file

@ -23,6 +23,7 @@ const settingsWithReadiness = (patrolReadiness: PatrolReadiness): AISettings =>
gemini_configured: false,
ollama_configured: true,
ollama_base_url: 'http://127.0.0.1:11434',
ollama_keep_alive: '30s',
configured_providers: ['ollama'],
patrol_readiness: patrolReadiness,
});

View file

@ -59,6 +59,7 @@ export interface AISettings {
gemini_configured: boolean; // true if Gemini API key is set
ollama_configured: boolean; // true (always available for attempt)
ollama_base_url: string; // Ollama server URL
ollama_keep_alive: string; // Ollama keep_alive value; empty uses the server default
openai_base_url?: string; // Custom OpenAI base URL
configured_providers: AIProvider[]; // List of providers with credentials
@ -124,6 +125,7 @@ export interface AISettingsUpdateRequest {
deepseek_api_key?: string; // Set DeepSeek API key
gemini_api_key?: string; // Set Gemini API key
ollama_base_url?: string; // Set Ollama server URL
ollama_keep_alive?: string; // Set Ollama keep_alive; empty uses the server default
openai_base_url?: string; // Set custom OpenAI base URL
// Clear flags for removing credentials
clear_anthropic_key?: boolean; // Clear Anthropic API key

View file

@ -2076,7 +2076,7 @@ func (s *Service) createProviderForModel(modelStr string) (providers.StreamingPr
return providers.NewGeminiClient(s.cfg.GeminiAPIKey, modelName, "", timeout), nil
case "ollama":
baseURL := s.cfg.GetBaseURLForProvider(config.AIProviderOllama)
return providers.NewOllamaClient(modelName, baseURL, s.cfg.OllamaUsername, s.cfg.OllamaPassword, timeout)
return providers.NewOllamaClientWithKeepAlive(modelName, baseURL, s.cfg.OllamaUsername, s.cfg.OllamaPassword, s.cfg.GetOllamaKeepAlive(), timeout)
case config.AIProviderQuickstart:
return nil, fmt.Errorf("quickstart provider is retired; configure a provider API key or Ollama")
default:

View file

@ -81,7 +81,7 @@ func NewForProvider(cfg *config.AIConfig, provider, model string) (Provider, err
case config.AIProviderOllama:
baseURL := cfg.GetBaseURLForProvider(config.AIProviderOllama)
return NewOllamaClient(model, baseURL, cfg.OllamaUsername, cfg.OllamaPassword, timeout)
return NewOllamaClientWithKeepAlive(model, baseURL, cfg.OllamaUsername, cfg.OllamaPassword, cfg.GetOllamaKeepAlive(), timeout)
case config.AIProviderGemini:
apiKey := cfg.GetAPIKeyForProvider(config.AIProviderGemini)

View file

@ -457,8 +457,9 @@ func TestNewFromConfig_MultiProviderFormat(t *testing.T) {
func TestNewForProvider_OllamaWithCustomBaseURL(t *testing.T) {
cfg := &config.AIConfig{
Enabled: true,
OllamaBaseURL: "http://custom-ollama:11434",
Enabled: true,
OllamaBaseURL: "http://custom-ollama:11434",
OllamaKeepAlive: "24h",
}
provider, err := NewForProvider(cfg, config.AIProviderOllama, "llama2")
if err != nil {
@ -467,6 +468,13 @@ func TestNewForProvider_OllamaWithCustomBaseURL(t *testing.T) {
if provider.Name() != "ollama" {
t.Errorf("Expected provider name 'ollama', got '%s'", provider.Name())
}
ollama, ok := provider.(*OllamaClient)
if !ok {
t.Fatalf("expected *OllamaClient, got %T", provider)
}
if ollama.keepAlive != "24h" {
t.Errorf("expected Ollama keepAlive 24h, got %q", ollama.keepAlive)
}
}
func TestNewForProvider_OpenAIWithCustomBaseURL(t *testing.T) {

View file

@ -7,9 +7,11 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
)
@ -27,6 +29,7 @@ type OllamaClient struct {
baseURL string
username string
password string
keepAlive string
client *http.Client // For non-streaming requests (has overall timeout)
streamClient *http.Client // For streaming requests (no overall timeout — relies on context)
}
@ -34,10 +37,21 @@ type OllamaClient struct {
// NewOllamaClient creates a new Ollama API client
// timeout is optional - pass 0 to use the default 5 minute timeout
func NewOllamaClient(model, baseURL, username, password string, timeout time.Duration) (*OllamaClient, error) {
return NewOllamaClientWithKeepAlive(model, baseURL, username, password, config.DefaultOllamaKeepAlive, timeout)
}
// NewOllamaClientWithKeepAlive creates a new Ollama API client with an
// explicit keep_alive request value. Pass an empty keepAlive string to omit
// keep_alive from requests and let the Ollama server default apply.
func NewOllamaClientWithKeepAlive(model, baseURL, username, password, keepAlive string, timeout time.Duration) (*OllamaClient, error) {
normalizedBaseURL, err := normalizeOllamaBaseURL(baseURL)
if err != nil {
return nil, err
}
normalizedKeepAlive, err := config.NormalizeOllamaKeepAlive(keepAlive)
if err != nil {
return nil, fmt.Errorf("normalize Ollama keep_alive: %w", err)
}
if timeout <= 0 {
timeout = 300 * time.Second // Default 5 minutes
}
@ -46,6 +60,7 @@ func NewOllamaClient(model, baseURL, username, password string, timeout time.Dur
baseURL: normalizedBaseURL,
username: username,
password: password,
keepAlive: normalizedKeepAlive,
client: newOllamaHTTPClient(timeout, false),
streamClient: newOllamaHTTPClient(timeout, true),
}, nil
@ -113,15 +128,19 @@ type ollamaRequest struct {
// (#1425). Pulse passes a short value so the model unloads shortly
// after a Patrol burst or one-shot analysis ends. Accepts duration
// strings like "30s", "5m", or "0" for immediate unload.
KeepAlive string `json:"keep_alive,omitempty"`
KeepAlive any `json:"keep_alive,omitempty"`
}
// ollamaKeepAlive is the duration Pulse asks Ollama to keep the model loaded
// after a request completes. 30s is short enough that the model unloads
// soon after a Patrol/alert-analysis burst ends, while still long enough
// to span the small gaps between sequential calls in a single analysis
// session so the model isn't reloaded mid-burst.
const ollamaKeepAlive = "30s"
func ollamaKeepAliveRequestValue(value string) any {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
if seconds, err := strconv.ParseFloat(value, 64); err == nil {
return seconds
}
return value
}
type ollamaMessage struct {
Role string `json:"role"`
@ -231,7 +250,7 @@ func (c *OllamaClient) Chat(ctx context.Context, req ChatRequest) (*ChatResponse
Model: model,
Messages: messages,
Stream: false, // Non-streaming for now
KeepAlive: ollamaKeepAlive,
KeepAlive: ollamaKeepAliveRequestValue(c.keepAlive),
}
// Convert tools to Ollama format
@ -401,7 +420,7 @@ func (c *OllamaClient) ChatStream(ctx context.Context, req ChatRequest, callback
Model: model,
Messages: messages,
Stream: true, // Enable streaming
KeepAlive: ollamaKeepAlive,
KeepAlive: ollamaKeepAliveRequestValue(c.keepAlive),
}
// Handle tools with tool_choice support (same as non-streaming)

View file

@ -8,6 +8,7 @@ import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -39,7 +40,7 @@ func TestOllamaClient_ChatStream_Success(t *testing.T) {
// #1425: Pulse must pass keep_alive so the model unloads shortly
// after the request burst ends instead of refreshing Ollama's
// 5-minute default TTL on every call.
assert.Equal(t, ollamaKeepAlive, req.KeepAlive)
assert.Equal(t, config.DefaultOllamaKeepAlive, req.KeepAlive)
w.Header().Set("Content-Type", "application/x-ndjson")
w.WriteHeader(http.StatusOK)
@ -185,7 +186,7 @@ func TestOllamaClient_Chat_Success(t *testing.T) {
assert.False(t, req.Stream)
assert.Equal(t, "llama3", req.Model)
// #1425: keep_alive must be set on non-streaming Chat too.
assert.Equal(t, ollamaKeepAlive, req.KeepAlive)
assert.Equal(t, config.DefaultOllamaKeepAlive, req.KeepAlive)
require.Len(t, req.Tools, 1)
assert.Equal(t, "function", req.Tools[0].Type)
assert.Equal(t, "get_time", req.Tools[0].Function.Name)
@ -230,6 +231,72 @@ func TestOllamaClient_Chat_Success(t *testing.T) {
assert.Equal(t, 3, resp.OutputTokens)
}
func TestOllamaClient_Chat_UsesConfiguredKeepAlive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req ollamaRequest
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
assert.Equal(t, "24h", req.KeepAlive)
_ = json.NewEncoder(w).Encode(ollamaResponse{
Model: "llama3",
Message: ollamaMessageResp{Role: "assistant", Content: "Hello"},
})
}))
defer server.Close()
client, err := NewOllamaClientWithKeepAlive("llama3", server.URL, "", "", "24h", 0)
require.NoError(t, err)
_, err = client.Chat(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "Hi"}},
})
require.NoError(t, err)
}
func TestOllamaClient_Chat_OmitsKeepAliveForServerDefault(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var raw map[string]json.RawMessage
require.NoError(t, json.NewDecoder(r.Body).Decode(&raw))
assert.NotContains(t, raw, "keep_alive")
_ = json.NewEncoder(w).Encode(ollamaResponse{
Model: "llama3",
Message: ollamaMessageResp{Role: "assistant", Content: "Hello"},
})
}))
defer server.Close()
client, err := NewOllamaClientWithKeepAlive("llama3", server.URL, "", "", "", 0)
require.NoError(t, err)
_, err = client.Chat(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "Hi"}},
})
require.NoError(t, err)
}
func TestOllamaClient_Chat_EncodesNumericKeepAlive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var raw map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&raw))
assert.Equal(t, float64(0), raw["keep_alive"])
_ = json.NewEncoder(w).Encode(ollamaResponse{
Model: "llama3",
Message: ollamaMessageResp{Role: "assistant", Content: "Hello"},
})
}))
defer server.Close()
client, err := NewOllamaClientWithKeepAlive("llama3", server.URL, "", "", "0", 0)
require.NoError(t, err)
_, err = client.Chat(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "Hi"}},
})
require.NoError(t, err)
}
func TestOllamaClient_TestConnection(t *testing.T) {
versionHits := 0
tagsHits := 0

View file

@ -1672,6 +1672,7 @@ func shouldRestartAIChat(req AISettingsUpdateRequest) bool {
req.OllamaBaseURL != nil ||
req.OllamaUsername != nil ||
req.OllamaPassword != nil ||
req.OllamaKeepAlive != nil ||
req.OpenAIBaseURL != nil ||
req.ClearAnthropicKey != nil ||
req.ClearOpenAIKey != nil ||
@ -1735,6 +1736,7 @@ func aiSettingsUpdateTouchesPatrolReadiness(req AISettingsUpdateRequest) bool {
req.OllamaBaseURL != nil ||
req.OllamaUsername != nil ||
req.OllamaPassword != nil ||
req.OllamaKeepAlive != nil ||
req.OpenAIBaseURL != nil ||
req.ClearAnthropicKey != nil ||
req.ClearOpenAIKey != nil ||
@ -2284,6 +2286,7 @@ type AISettingsResponse struct {
OllamaBaseURL string `json:"ollama_base_url"` // Ollama server URL
OllamaUsername string `json:"ollama_username,omitempty"` // Optional Basic Auth username for Ollama
OllamaPasswordSet bool `json:"ollama_password_set"` // true if an Ollama password is stored
OllamaKeepAlive string `json:"ollama_keep_alive"` // Ollama keep_alive value; empty uses server default
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI base URL
ConfiguredProviders []string `json:"configured_providers"` // List of provider names with credentials
// Cost controls
@ -2365,6 +2368,7 @@ type AISettingsUpdateRequest struct {
OllamaBaseURL *string `json:"ollama_base_url,omitempty"` // Set Ollama server URL
OllamaUsername *string `json:"ollama_username,omitempty"` // Set Ollama Basic Auth username
OllamaPassword *string `json:"ollama_password,omitempty"` // Set Ollama Basic Auth password
OllamaKeepAlive *string `json:"ollama_keep_alive,omitempty"` // Set Ollama keep_alive; empty uses server default
OpenAIBaseURL *string `json:"openai_base_url,omitempty"` // Set custom OpenAI base URL
// Clear flags for removing credentials
ClearAnthropicKey *bool `json:"clear_anthropic_key,omitempty"` // Clear Anthropic API key
@ -2501,6 +2505,7 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R
OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama),
OllamaUsername: settings.OllamaUsername,
OllamaPasswordSet: settings.OllamaPassword != "",
OllamaKeepAlive: settings.GetOllamaKeepAlive(),
OpenAIBaseURL: settings.OpenAIBaseURL,
ConfiguredProviders: settings.GetConfiguredProviders(),
CostBudgetUSD30d: settings.CostBudgetUSD30d,
@ -2650,6 +2655,14 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
} else if req.OllamaPassword != nil {
settings.OllamaPassword = *req.OllamaPassword
}
if req.OllamaKeepAlive != nil {
keepAlive, err := config.NormalizeOllamaKeepAlive(*req.OllamaKeepAlive)
if err != nil {
http.Error(w, "ollama_keep_alive "+err.Error(), http.StatusBadRequest)
return
}
settings.OllamaKeepAlive = keepAlive
}
if req.OpenAIBaseURL != nil {
settings.OpenAIBaseURL = strings.TrimSpace(*req.OpenAIBaseURL)
}
@ -2917,6 +2930,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama),
OllamaUsername: settings.OllamaUsername,
OllamaPasswordSet: settings.OllamaPassword != "",
OllamaKeepAlive: settings.GetOllamaKeepAlive(),
OpenAIBaseURL: settings.OpenAIBaseURL,
ConfiguredProviders: settings.GetConfiguredProviders(),
RequestTimeoutSeconds: settings.RequestTimeoutSeconds,

View file

@ -423,11 +423,12 @@ func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) {
// Update settings to enable AI via Ollama.
{
body, _ := json.Marshal(AISettingsUpdateRequest{
Enabled: ptr(true),
Model: ptr("ollama:llama3"),
OllamaBaseURL: ptr("http://localhost:11434"),
OllamaUsername: ptr("unai"),
OllamaPassword: ptr("secret"),
Enabled: ptr(true),
Model: ptr("ollama:llama3"),
OllamaBaseURL: ptr("http://localhost:11434"),
OllamaUsername: ptr("unai"),
OllamaPassword: ptr("secret"),
OllamaKeepAlive: ptr("24h"),
})
req := newLoopbackRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body))
rec := httptest.NewRecorder()
@ -450,6 +451,9 @@ func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) {
if resp.OllamaUsername != "unai" || !resp.OllamaPasswordSet {
t.Fatalf("expected ollama auth state in response, got %+v", resp)
}
if resp.OllamaKeepAlive != "24h" {
t.Fatalf("expected ollama keep alive in response, got %+v", resp)
}
responseBody := rec.Body.String()
if !strings.Contains(responseBody, `"available_models":[]`) ||
!strings.Contains(responseBody, `"configured_providers":[`) ||
@ -478,6 +482,9 @@ func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) {
if resp.OllamaUsername != "unai" || !resp.OllamaPasswordSet {
t.Fatalf("expected persisted ollama auth state, got %+v", resp)
}
if resp.OllamaKeepAlive != "24h" {
t.Fatalf("expected persisted ollama keep alive, got %+v", resp)
}
responseBody := rec.Body.String()
if !strings.Contains(responseBody, `"available_models":[]`) ||
!strings.Contains(responseBody, `"configured_providers":[`) ||
@ -518,6 +525,73 @@ func TestAISettingsHandler_GetSettingsClampsPaidControlsToEntitlements(t *testin
require.False(t, resp.AlertTriggeredAnalysis)
}
func TestAISettingsHandler_UpdateSettings_OllamaKeepAlive(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
body, err := json.Marshal(AISettingsUpdateRequest{
Enabled: ptr(true),
Model: ptr("ollama:llama3"),
OllamaBaseURL: ptr("http://127.0.0.1:11434"),
OllamaKeepAlive: ptr(""),
})
require.NoError(t, err)
req := newLoopbackRequest(http.MethodPut, "/api/settings/ai/update", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleUpdateAISettings(rec, req)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var resp AISettingsResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Empty(t, resp.OllamaKeepAlive)
saved, err := persistence.LoadAIConfig()
require.NoError(t, err)
require.Empty(t, saved.OllamaKeepAlive)
body, err = json.Marshal(AISettingsUpdateRequest{
OllamaKeepAlive: ptr("24h"),
})
require.NoError(t, err)
req = newLoopbackRequest(http.MethodPut, "/api/settings/ai/update", bytes.NewReader(body))
rec = httptest.NewRecorder()
handler.HandleUpdateAISettings(rec, req)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, "24h", resp.OllamaKeepAlive)
saved, err = persistence.LoadAIConfig()
require.NoError(t, err)
require.Equal(t, "24h", saved.OllamaKeepAlive)
}
func TestAISettingsHandler_UpdateSettingsRejectsInvalidOllamaKeepAlive(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
body, err := json.Marshal(AISettingsUpdateRequest{
OllamaKeepAlive: ptr("forever"),
})
require.NoError(t, err)
req := newLoopbackRequest(http.MethodPut, "/api/settings/ai/update", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleUpdateAISettings(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
require.Contains(t, rec.Body.String(), "ollama_keep_alive")
}
func TestAISettingsHandler_GetAIService_TenantPatrolUsesCanonicalProviders(t *testing.T) {
tmp := t.TempDir()
mtp := config.NewMultiTenantPersistence(tmp)

View file

@ -34,6 +34,12 @@ var (
const privilegedBrowserSessionMaxAge = 5 * time.Minute
const (
oidcMaxRefreshLead = 5 * time.Minute
oidcFallbackRefreshLead = time.Minute
oidcRefreshLifetimePart = 5
)
type authRequirementFailure struct {
Status int
Code string
@ -691,9 +697,8 @@ func checkAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request, write
Msg("Rejected recovery session outside direct loopback binding")
} else if ValidateAndExtendSession(cookie.Value) {
username := GetSessionUsername(cookie.Value)
if session != nil && session.OIDCRefreshToken != "" && hasEnabledSSOProvidersForAuth(cfg) {
// Check if access token is expired or about to expire (5 min buffer)
if time.Now().Add(5 * time.Minute).After(session.OIDCAccessTokenExp) {
if session != nil && hasEnabledSSOProvidersForAuth(cfg) {
if shouldRefreshOIDCSessionToken(time.Now(), session) {
go refreshOIDCSessionTokens(cfg, cookie.Value, session)
}
}
@ -1390,6 +1395,42 @@ func adminBypassEnabled() bool {
return adminBypassState.enabled
}
func oidcRefreshLead(session *SessionData) time.Duration {
if session == nil || session.OIDCAccessTokenExp.IsZero() {
return 0
}
issuedAt := session.OIDCAccessTokenIssuedAt
if issuedAt.IsZero() || !session.OIDCAccessTokenExp.After(issuedAt) {
return oidcFallbackRefreshLead
}
lifetime := session.OIDCAccessTokenExp.Sub(issuedAt)
lead := lifetime / oidcRefreshLifetimePart
if lead > oidcMaxRefreshLead {
return oidcMaxRefreshLead
}
return lead
}
func shouldRefreshOIDCSessionToken(now time.Time, session *SessionData) bool {
if session == nil || session.OIDCRefreshToken == "" || session.OIDCTokenRefreshing {
return false
}
if session.OIDCAccessTokenExp.IsZero() {
return false
}
if !now.Before(session.OIDCAccessTokenExp) {
return true
}
lead := oidcRefreshLead(session)
if lead <= 0 {
return false
}
return !now.Add(lead).Before(session.OIDCAccessTokenExp)
}
// oidcRefreshMutex prevents concurrent refresh attempts for the same session
var oidcRefreshMutex sync.Map

View file

@ -244,6 +244,49 @@ func TestRefreshOIDCSessionTokens_OIDCDisabledDoesNotInvalidate(t *testing.T) {
}
}
func TestShouldRefreshOIDCSessionToken_DoesNotRefreshFreshFiveMinuteToken(t *testing.T) {
now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC)
session := &SessionData{
OIDCRefreshToken: "refresh",
OIDCAccessTokenIssuedAt: now,
OIDCAccessTokenExp: now.Add(5 * time.Minute),
}
if shouldRefreshOIDCSessionToken(now, session) {
t.Fatal("fresh 300-second access token should not refresh immediately")
}
legacySession := &SessionData{
OIDCRefreshToken: "refresh",
OIDCAccessTokenExp: now.Add(5 * time.Minute),
}
if shouldRefreshOIDCSessionToken(now, legacySession) {
t.Fatal("fresh 300-second access token without issued-at metadata should not refresh immediately")
}
}
func TestShouldRefreshOIDCSessionToken_RefreshesNearExpiryAndExpiredToken(t *testing.T) {
now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC)
nearExpiry := &SessionData{
OIDCRefreshToken: "refresh",
OIDCAccessTokenIssuedAt: now,
OIDCAccessTokenExp: now.Add(5 * time.Minute),
}
if !shouldRefreshOIDCSessionToken(now.Add(4*time.Minute), nearExpiry) {
t.Fatal("300-second access token should refresh inside its final relative refresh window")
}
expired := &SessionData{
OIDCRefreshToken: "refresh",
OIDCAccessTokenIssuedAt: now.Add(-5 * time.Minute),
OIDCAccessTokenExp: now.Add(-time.Second),
}
if !shouldRefreshOIDCSessionToken(now, expired) {
t.Fatal("expired access token should refresh")
}
}
func TestBuildSSOAuthSnapshotNormalizesEmptyProviders(t *testing.T) {
snapshot := buildSSOAuthSnapshot(nil)
if snapshot.OIDCProviders == nil {

View file

@ -1107,9 +1107,18 @@ func TestPBSSetupScript_FailsClosedOnAutoRegisterSuccessDetection(t *testing.T)
if !containsString(script, `curl -fsS -X POST "$PULSE_URL/api/auto-register"`) {
t.Fatalf("expected fail-fast PBS auto-register transport in setup script, got: %s", truncate(script, 900))
}
if !containsString(script, `"source": "script"`) {
if !containsString(script, `"source":"script"`) {
t.Fatalf("expected PBS setup script to use canonical /api/auto-register source marker, got: %s", truncate(script, 900))
}
if !containsString(script, `PULSE_URL="http://pulse.local:7656"`) {
t.Fatalf("expected PBS auto-registration to target the Pulse base URL, got: %s", truncate(script, 1200))
}
if containsString(script, `PULSE_URL="http://pulse.local:7656/api/setup-script?`) {
t.Fatalf("expected PBS auto-registration URL not to reuse the setup-script download URL, got: %s", truncate(script, 1200))
}
if containsString(script, `"source": "script",`) || containsString(script, `"source":"script",`) {
t.Fatalf("expected PBS auto-register JSON not to preserve a trailing comma after source, got: %s", truncate(script, 1200))
}
if !containsString(script, `REGISTER_RC=$?`) {
t.Fatalf("expected explicit PBS auto-register curl exit-code handling in setup script, got: %s", truncate(script, 900))
}

View file

@ -1265,6 +1265,7 @@ func TestContract_AISettingsUpdateProviderResolutionJSONSnapshot(t *testing.T) {
"ollama_configured":true,
"ollama_base_url":%q,
"ollama_password_set":false,
"ollama_keep_alive":"30s",
"configured_providers":["ollama"],
"control_level":"read_only",
"protected_guests":[],
@ -1406,12 +1407,13 @@ func TestContract_AISettingsBYOKOverrideDoesNotExposeQuickstartInventoryJSONSnap
"ollama_configured":false,
"ollama_base_url":"http://localhost:11434",
"ollama_password_set":false,
"configured_providers":["openai"],
"control_level":"read_only",
"protected_guests":[],
"discovery_enabled":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"}]}
}`
"ollama_keep_alive":"30s",
"configured_providers":["openai"],
"control_level":"read_only",
"protected_guests":[],
"discovery_enabled":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"}]}
}`
assertJSONSnapshot(t, rec.Body.Bytes(), want)
}
@ -3408,6 +3410,7 @@ func TestContract_HostedAISettingsDoesNotAutoBootstrapQuickstartJSONSnapshot(t *
"ollama_configured":false,
"ollama_base_url":"http://localhost:11434",
"ollama_password_set":false,
"ollama_keep_alive":"30s",
"configured_providers":[],
"control_level":"read_only",
"protected_guests":[],
@ -3468,6 +3471,7 @@ func TestContract_AISettingsRetiredQuickstartAliasJSONSnapshot(t *testing.T) {
"ollama_configured":false,
"ollama_base_url":"http://localhost:11434",
"ollama_password_set":false,
"ollama_keep_alive":"30s",
"configured_providers":[],
"control_level":"read_only",
"protected_guests":[],
@ -3533,12 +3537,13 @@ func TestContract_AISettingsOllamaAuthJSONSnapshot(t *testing.T) {
"ollama_base_url":"http://ollama.example:11434",
"ollama_username":"unai",
"ollama_password_set":true,
"configured_providers":["ollama"],
"control_level":"read_only",
"protected_guests":[],
"discovery_enabled":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"}]}
}`
"ollama_keep_alive":"30s",
"configured_providers":["ollama"],
"control_level":"read_only",
"protected_guests":[],
"discovery_enabled":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"}]}
}`
assertJSONSnapshot(t, rec.Body.Bytes(), want)
}
@ -4027,6 +4032,7 @@ func TestContract_HostedTenantAISettingsDoesNotAutoBootstrapQuickstartJSONSnapsh
"ollama_configured":false,
"ollama_base_url":"http://localhost:11434",
"ollama_password_set":false,
"ollama_keep_alive":"30s",
"configured_providers":[],
"control_level":"read_only",
"protected_guests":[],

View file

@ -54,6 +54,13 @@ func TestCreateOIDCSession(t *testing.T) {
if session.OIDCAccessTokenExp.IsZero() {
t.Error("AccessTokenExp should not be zero")
}
if session.OIDCAccessTokenIssuedAt.IsZero() {
t.Error("AccessTokenIssuedAt should not be zero")
}
if !session.OIDCAccessTokenIssuedAt.Before(session.OIDCAccessTokenExp) {
t.Error("AccessTokenIssuedAt should be before AccessTokenExp")
}
}
func TestUpdateOIDCTokens(t *testing.T) {
@ -77,6 +84,7 @@ func TestUpdateOIDCTokens(t *testing.T) {
store.CreateOIDCSession(token, 24*time.Hour, "TestAgent", "127.0.0.1", "testuser", oidcInfo)
// Update the tokens (simulating a refresh)
beforeRefresh := time.Now()
newExpiry := time.Now().Add(2 * time.Hour)
store.UpdateOIDCTokens(token, "new-refresh-token", newExpiry)
@ -93,6 +101,10 @@ func TestUpdateOIDCTokens(t *testing.T) {
if !session.OIDCAccessTokenExp.After(originalExpiry) {
t.Error("AccessTokenExp should be updated to new expiry")
}
if session.OIDCAccessTokenIssuedAt.Before(beforeRefresh) {
t.Error("AccessTokenIssuedAt should update when tokens refresh")
}
}
func TestOIDCSessionPersistence(t *testing.T) {

View file

@ -40,11 +40,12 @@ type sessionPersisted struct {
IP string `json:"ip,omitempty"`
OriginalDuration time.Duration `json:"original_duration,omitempty"`
// OIDC token fields for refresh token support
OIDCRefreshToken string `json:"oidc_refresh_token,omitempty"`
OIDCAccessTokenExp time.Time `json:"oidc_access_token_exp,omitempty"`
OIDCIssuer string `json:"oidc_issuer,omitempty"`
OIDCClientID string `json:"oidc_client_id,omitempty"`
OIDCTokenRefreshing bool `json:"-"` // transient, not persisted
OIDCRefreshToken string `json:"oidc_refresh_token,omitempty"`
OIDCAccessTokenExp time.Time `json:"oidc_access_token_exp,omitempty"`
OIDCAccessTokenIssuedAt time.Time `json:"oidc_access_token_issued_at,omitempty"`
OIDCIssuer string `json:"oidc_issuer,omitempty"`
OIDCClientID string `json:"oidc_client_id,omitempty"`
OIDCTokenRefreshing bool `json:"-"` // transient, not persisted
// SAML session fields for Single Logout (SLO) support
SAMLProviderID string `json:"saml_provider_id,omitempty"`
SAMLNameID string `json:"saml_name_id,omitempty"`
@ -61,11 +62,12 @@ type SessionData struct {
IP string `json:"ip,omitempty"`
OriginalDuration time.Duration `json:"original_duration,omitempty"` // Track original duration for sliding expiration
// OIDC token fields for refresh token support
OIDCRefreshToken string `json:"oidc_refresh_token,omitempty"` // Encrypted at rest
OIDCAccessTokenExp time.Time `json:"oidc_access_token_exp,omitempty"` // When the access token expires
OIDCIssuer string `json:"oidc_issuer,omitempty"` // IdP issuer URL
OIDCClientID string `json:"oidc_client_id,omitempty"` // OIDC client ID
OIDCTokenRefreshing bool `json:"-"` // Prevents concurrent refresh attempts
OIDCRefreshToken string `json:"oidc_refresh_token,omitempty"` // Encrypted at rest
OIDCAccessTokenExp time.Time `json:"oidc_access_token_exp,omitempty"` // When the access token expires
OIDCAccessTokenIssuedAt time.Time `json:"oidc_access_token_issued_at,omitempty"` // When this access token was received
OIDCIssuer string `json:"oidc_issuer,omitempty"` // IdP issuer URL
OIDCClientID string `json:"oidc_client_id,omitempty"` // OIDC client ID
OIDCTokenRefreshing bool `json:"-"` // Prevents concurrent refresh attempts
// SAML session fields for Single Logout (SLO) support
SAMLProviderID string `json:"saml_provider_id,omitempty"` // SAML IdP provider ID
SAMLNameID string `json:"saml_name_id,omitempty"` // SAML NameID from assertion
@ -100,20 +102,21 @@ func (s *SessionStore) loadHashedSessions(persisted []sessionPersisted, now time
}
s.sessions[entry.Key] = &SessionData{
Username: entry.Username,
RecoveryBypass: entry.RecoveryBypass,
ExpiresAt: entry.ExpiresAt,
CreatedAt: entry.CreatedAt,
UserAgent: entry.UserAgent,
IP: entry.IP,
OriginalDuration: entry.OriginalDuration,
OIDCRefreshToken: refreshToken,
OIDCAccessTokenExp: entry.OIDCAccessTokenExp,
OIDCIssuer: entry.OIDCIssuer,
OIDCClientID: entry.OIDCClientID,
SAMLProviderID: entry.SAMLProviderID,
SAMLNameID: entry.SAMLNameID,
SAMLSessionIndex: entry.SAMLSessionIndex,
Username: entry.Username,
RecoveryBypass: entry.RecoveryBypass,
ExpiresAt: entry.ExpiresAt,
CreatedAt: entry.CreatedAt,
UserAgent: entry.UserAgent,
IP: entry.IP,
OriginalDuration: entry.OriginalDuration,
OIDCRefreshToken: refreshToken,
OIDCAccessTokenExp: entry.OIDCAccessTokenExp,
OIDCAccessTokenIssuedAt: entry.OIDCAccessTokenIssuedAt,
OIDCIssuer: entry.OIDCIssuer,
OIDCClientID: entry.OIDCClientID,
SAMLProviderID: entry.SAMLProviderID,
SAMLNameID: entry.SAMLNameID,
SAMLSessionIndex: entry.SAMLSessionIndex,
}
loaded++
}
@ -247,6 +250,7 @@ func (s *SessionStore) CreateRecoverySession(token string, duration time.Duratio
type OIDCTokenInfo struct {
RefreshToken string
AccessTokenExp time.Time
IssuedAt time.Time
Issuer string
ClientID string
}
@ -256,20 +260,26 @@ func (s *SessionStore) CreateOIDCSession(token string, duration time.Duration, u
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
key := sessionHash(token)
session := &SessionData{
Username: username,
RecoveryBypass: false,
ExpiresAt: time.Now().Add(duration),
CreatedAt: time.Now(),
ExpiresAt: now.Add(duration),
CreatedAt: now,
UserAgent: userAgent,
IP: ip,
OriginalDuration: duration,
}
if oidc != nil {
issuedAt := oidc.IssuedAt
if issuedAt.IsZero() {
issuedAt = now
}
session.OIDCRefreshToken = oidc.RefreshToken
session.OIDCAccessTokenExp = oidc.AccessTokenExp
session.OIDCAccessTokenIssuedAt = issuedAt
session.OIDCIssuer = oidc.Issuer
session.OIDCClientID = oidc.ClientID
}
@ -352,6 +362,7 @@ func (s *SessionStore) UpdateOIDCTokens(token string, refreshToken string, acces
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
key := sessionHash(token)
session, exists := s.sessions[key]
if !exists {
@ -360,11 +371,12 @@ func (s *SessionStore) UpdateOIDCTokens(token string, refreshToken string, acces
session.OIDCRefreshToken = refreshToken
session.OIDCAccessTokenExp = accessTokenExp
session.OIDCAccessTokenIssuedAt = now
session.OIDCTokenRefreshing = false
// Also extend the session expiry since the token is still valid
if session.OriginalDuration > 0 {
session.ExpiresAt = time.Now().Add(session.OriginalDuration)
session.ExpiresAt = now.Add(session.OriginalDuration)
}
// Save immediately after token refresh
@ -486,21 +498,22 @@ func (s *SessionStore) saveUnsafe() {
}
persisted = append(persisted, sessionPersisted{
Key: key,
Username: session.Username,
RecoveryBypass: session.RecoveryBypass,
ExpiresAt: session.ExpiresAt,
CreatedAt: session.CreatedAt,
UserAgent: session.UserAgent,
IP: session.IP,
OriginalDuration: session.OriginalDuration,
OIDCRefreshToken: refreshToken,
OIDCAccessTokenExp: session.OIDCAccessTokenExp,
OIDCIssuer: session.OIDCIssuer,
OIDCClientID: session.OIDCClientID,
SAMLProviderID: session.SAMLProviderID,
SAMLNameID: session.SAMLNameID,
SAMLSessionIndex: session.SAMLSessionIndex,
Key: key,
Username: session.Username,
RecoveryBypass: session.RecoveryBypass,
ExpiresAt: session.ExpiresAt,
CreatedAt: session.CreatedAt,
UserAgent: session.UserAgent,
IP: session.IP,
OriginalDuration: session.OriginalDuration,
OIDCRefreshToken: refreshToken,
OIDCAccessTokenExp: session.OIDCAccessTokenExp,
OIDCAccessTokenIssuedAt: session.OIDCAccessTokenIssuedAt,
OIDCIssuer: session.OIDCIssuer,
OIDCClientID: session.OIDCClientID,
SAMLProviderID: session.SAMLProviderID,
SAMLNameID: session.SAMLNameID,
SAMLSessionIndex: session.SAMLSessionIndex,
})
}

View file

@ -204,6 +204,38 @@ func TestSessionStore_CreateRecoverySession_PersistsRecoveryBypass(t *testing.T)
}
}
func TestSessionStore_CreateOIDCSession_PersistsAccessTokenIssuedAt(t *testing.T) {
tmpDir := t.TempDir()
issuedAt := time.Unix(1_700_000_000, 0).UTC()
expiresAt := issuedAt.Add(5 * time.Minute)
token := "oidc-issued-at-token"
store := NewSessionStore(tmpDir)
store.CreateOIDCSession(token, time.Hour, "TestAgent", "127.0.0.1", "testuser", &OIDCTokenInfo{
RefreshToken: "refresh-token",
AccessTokenExp: expiresAt,
IssuedAt: issuedAt,
Issuer: "https://issuer.example",
ClientID: "client-id",
})
store.Shutdown()
reloaded := NewSessionStore(tmpDir)
defer reloaded.Shutdown()
session := reloaded.GetSession(token)
if session == nil {
t.Fatal("expected reloaded OIDC session to exist")
}
if !session.OIDCAccessTokenIssuedAt.Equal(issuedAt) {
t.Fatalf("OIDCAccessTokenIssuedAt = %s, want %s", session.OIDCAccessTokenIssuedAt, issuedAt)
}
if !session.OIDCAccessTokenExp.Equal(expiresAt) {
t.Fatalf("OIDCAccessTokenExp = %s, want %s", session.OIDCAccessTokenExp, expiresAt)
}
}
func TestSessionStore_ValidateSession_NonExistent(t *testing.T) {
store := &SessionStore{
sessions: make(map[string]*SessionData),

View file

@ -1258,26 +1258,13 @@ else
echo ""
# Construct registration request with setup token
REGISTER_JSON=$(cat <<EOF
{
"type": "pbs",
"host": "$HOST_URL",
"serverName": "$SERVER_HOSTNAME",
"tokenId": "$PULSE_TOKEN_ID",
"tokenValue": "$TOKEN_VALUE",
"authToken": "$PULSE_SETUP_TOKEN",
"source": "script",
}
EOF
)
# Remove newlines from JSON
REGISTER_JSON=$(echo "$REGISTER_JSON" | tr -d '\n')
REGISTER_JSON='{"type":"pbs","host":"'"$HOST_URL"'","serverName":"'"$SERVER_HOSTNAME"'","tokenId":"'"$PULSE_TOKEN_ID"'","tokenValue":"'"$TOKEN_VALUE"'","authToken":"'"$PULSE_SETUP_TOKEN"'","source":"script"}'
# Send registration with setup token
REGISTER_ATTEMPTED=true
REGISTER_RESPONSE=$(curl -fsS -X POST "$PULSE_URL/api/auto-register" \
REGISTER_RESPONSE=$(echo "$REGISTER_JSON" | curl -fsS -X POST "$PULSE_URL/api/auto-register" \
-H "Content-Type: application/json" \
-d "$REGISTER_JSON" 2>&1)
-d @- 2>&1)
REGISTER_RC=$?
else
echo "⚠️ Auto-registration skipped: no setup token provided"
@ -1362,5 +1349,5 @@ if [ "$AUTO_REG_SUCCESS" != true ] && [ "$SETUP_TOKEN_INVALID" != true ]; then
fi
fi
`, ctx.ServerName, time.Now().Format("2006-01-02 15:04:05"), ctx.TokenMatchPrefix,
ctx.TokenName, ctx.ServerHost, ctx.SetupToken, ctx.Artifact.URL)
ctx.TokenName, ctx.ServerHost, ctx.SetupToken, ctx.PulseURL)
}

View file

@ -1,6 +1,9 @@
package config
import (
"fmt"
"math"
"strconv"
"strings"
"time"
)
@ -40,6 +43,7 @@ type AIConfig struct {
OllamaBaseURL string `json:"ollama_base_url,omitempty"` // Ollama server URL (default: http://localhost:11434)
OllamaUsername string `json:"ollama_username,omitempty"` // Optional Basic Auth username for Ollama
OllamaPassword string `json:"ollama_password,omitempty"` // Optional Basic Auth password for Ollama
OllamaKeepAlive string `json:"ollama_keep_alive"` // Ollama keep_alive value; empty uses the server default
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI-compatible base URL (optional)
// OAuth fields for Claude Pro/Max subscription authentication
@ -140,6 +144,7 @@ const (
// Pulse-hosted model aliases from pre-GA config.
DefaultAIModelQuickstart = "pulse-hosted"
DefaultOllamaBaseURL = "http://localhost:11434"
DefaultOllamaKeepAlive = "30s"
DefaultOpenRouterBaseURL = "https://openrouter.ai/api/v1/chat/completions"
DefaultDeepSeekBaseURL = "https://api.deepseek.com/chat/completions"
DefaultGeminiBaseURL = "https://generativelanguage.googleapis.com/v1beta"
@ -188,6 +193,9 @@ func NewDefaultAIConfig() *AIConfig {
Enabled: false,
Model: "",
AuthMethod: AuthMethodAPIKey,
// Pulse keeps the v6 cost-control default explicit. Operators can
// clear this value to let the Ollama server's own default apply.
OllamaKeepAlive: DefaultOllamaKeepAlive,
// Patrol defaults - enabled when AI is enabled
// Default to 6 hour intervals (much more token-efficient than 15 min)
PatrolEnabled: true,
@ -205,6 +213,36 @@ func NewDefaultAIConfig() *AIConfig {
}
}
// NormalizeOllamaKeepAlive validates the value Pulse sends as Ollama's
// keep_alive request option. Empty is intentional and means "omit keep_alive"
// so the Ollama server default applies.
func NormalizeOllamaKeepAlive(value string) (string, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "", nil
}
if numeric, err := strconv.ParseFloat(trimmed, 64); err == nil {
if math.IsInf(numeric, 0) || math.IsNaN(numeric) {
return "", fmt.Errorf("must be a finite duration or second count")
}
return trimmed, nil
}
if _, err := time.ParseDuration(trimmed); err == nil {
return trimmed, nil
}
return "", fmt.Errorf("must be a duration such as 30s, 5m, or 24h; seconds such as 3600; -1 to keep loaded; 0 to unload; or empty to use the Ollama server default")
}
// GetOllamaKeepAlive returns the configured keep_alive value. A nil config
// keeps the Pulse default; an explicitly empty config value is preserved so
// callers can omit keep_alive and defer to the Ollama server.
func (c *AIConfig) GetOllamaKeepAlive() string {
if c == nil {
return DefaultOllamaKeepAlive
}
return strings.TrimSpace(c.OllamaKeepAlive)
}
// IsConfigured returns true if the AI config has enough info to make API calls
// For multi-provider setup, returns true if ANY provider is configured
func (c *AIConfig) IsConfigured() bool {

View file

@ -392,6 +392,44 @@ func TestAIConfig_GetBaseURLForProvider(t *testing.T) {
}
func TestAIConfig_OllamaKeepAliveDefaultsAndValidation(t *testing.T) {
t.Run("default config keeps pulse keep alive", func(t *testing.T) {
cfg := NewDefaultAIConfig()
if got := cfg.GetOllamaKeepAlive(); got != DefaultOllamaKeepAlive {
t.Fatalf("GetOllamaKeepAlive() = %q, want %q", got, DefaultOllamaKeepAlive)
}
})
t.Run("empty preserves server default intent", func(t *testing.T) {
cfg := &AIConfig{OllamaKeepAlive: ""}
if got := cfg.GetOllamaKeepAlive(); got != "" {
t.Fatalf("GetOllamaKeepAlive() = %q, want empty", got)
}
})
valid := []string{"30s", "5m", "24h", "0", "-1", "3600"}
for _, value := range valid {
t.Run("valid "+value, func(t *testing.T) {
got, err := NormalizeOllamaKeepAlive(" " + value + " ")
if err != nil {
t.Fatalf("NormalizeOllamaKeepAlive(%q) returned error: %v", value, err)
}
if got != value {
t.Fatalf("NormalizeOllamaKeepAlive(%q) = %q", value, got)
}
})
}
invalid := []string{"later", "forever", "NaN", "+Inf"}
for _, value := range invalid {
t.Run("invalid "+value, func(t *testing.T) {
if _, err := NormalizeOllamaKeepAlive(value); err == nil {
t.Fatalf("NormalizeOllamaKeepAlive(%q) returned nil error", value)
}
})
}
}
func TestAIConfig_IsUsingOAuth(t *testing.T) {
tests := []struct {
name string

View file

@ -10,6 +10,7 @@ import (
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rs/zerolog/log"
)
@ -214,7 +215,7 @@ func (s *HostContinuityStore) Match(
if matchedByAlias {
continue
}
if hostname == "" || !strings.EqualFold(entry.Hostname, hostname) {
if !hostContinuityHostnameMatches(entry.Hostname, hostname) {
continue
}
if tokenID != "" && (entry.TokenID == "" || entry.TokenID != tokenID) {
@ -229,6 +230,15 @@ func (s *HostContinuityStore) Match(
return best, matched
}
func hostContinuityHostnameMatches(left, right string) bool {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
if left == "" || right == "" {
return false
}
return strings.EqualFold(left, right) || unifiedresources.HostnamesEquivalent(left, right)
}
func uniqueTrimmedStrings(values ...string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))

View file

@ -41,6 +41,16 @@ func TestHostContinuityStoreRoundTripAndMatch(t *testing.T) {
t.Fatalf("matched host ID = %q, want %q", entry.HostID, "host-1")
}
if entry, ok := reloaded.Match("", "", "", "host-1", "token-1", now.Add(-time.Minute)); !ok {
t.Fatal("expected match by equivalent short hostname and token")
} else if entry.HostID != "host-1" {
t.Fatalf("matched host ID = %q, want %q", entry.HostID, "host-1")
}
if _, ok := reloaded.Match("", "", "", "host-1.example", "token-1", now.Add(-time.Minute)); ok {
t.Fatal("expected distinct fully-qualified hostnames not to match")
}
if _, ok := reloaded.Match("", "", "", "host-1.local", "token-2", now.Add(-time.Minute)); ok {
t.Fatal("expected token mismatch not to match")
}

View file

@ -1957,6 +1957,11 @@ func (c *ConfigPersistence) SaveAIConfig(settings AIConfig) error {
settings.NormalizePatrolEventTriggerSettings()
settings.NormalizeQuickstartModelAliases()
ollamaKeepAlive, err := NormalizeOllamaKeepAlive(settings.OllamaKeepAlive)
if err != nil {
return fmt.Errorf("invalid Ollama keep-alive setting: %w", err)
}
settings.OllamaKeepAlive = ollamaKeepAlive
if err := c.EnsureConfigDir(); err != nil {
return fmt.Errorf("prepare config directory for ai config: %w", err)

View file

@ -165,6 +165,39 @@ func TestPersistence_AIConfig(t *testing.T) {
assert.Error(t, err)
}
func TestPersistence_AIConfig_OllamaKeepAliveDefaultsAndExplicitServerDefault(t *testing.T) {
t.Run("missing field loads pulse default", func(t *testing.T) {
tempDir := t.TempDir()
p := NewConfigPersistence(tempDir)
legacy := map[string]interface{}{
"enabled": true,
"model": "ollama:llama3",
"ollama_base_url": "http://127.0.0.1:11434",
}
data, err := json.Marshal(legacy)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "ai.enc"), data, 0o600))
loaded, err := p.LoadAIConfig()
require.NoError(t, err)
require.Equal(t, DefaultOllamaKeepAlive, loaded.OllamaKeepAlive)
})
t.Run("empty field survives reload as server default", func(t *testing.T) {
tempDir := t.TempDir()
p := NewConfigPersistence(tempDir)
cfg := NewDefaultAIConfig()
cfg.OllamaKeepAlive = ""
require.NoError(t, p.SaveAIConfig(*cfg))
loaded, err := p.LoadAIConfig()
require.NoError(t, err)
require.Empty(t, loaded.OllamaKeepAlive)
})
}
func TestPersistence_HasAIConfig(t *testing.T) {
tempDir := t.TempDir()
p := NewConfigPersistence(tempDir)

View file

@ -177,14 +177,21 @@ func (m *Monitor) RemoveHostAgent(hostID string) (models.Host, error) {
}
if hostname != "" {
key := fmt.Sprintf("%s:%s", tokenID, hostname)
if _, exists := m.hostTokenBindings[key]; exists {
key := hostTokenBindingKey(tokenID, hostname)
delete(m.hostTokenBindings, key)
}
prefix := tokenID + ":"
for key, boundID := range m.hostTokenBindings {
if !strings.HasPrefix(key, prefix) {
continue
}
if strings.TrimSpace(boundID) == hostID {
delete(m.hostTokenBindings, key)
}
}
if tokenRemoved != nil {
prefix := tokenID + ":"
for key := range m.hostTokenBindings {
if strings.HasPrefix(key, prefix) {
delete(m.hostTokenBindings, key)
@ -289,7 +296,7 @@ func (m *Monitor) lookupRemovedHostAgent(identifier, hostname string) (time.Time
if strings.TrimSpace(entry.ID) == identifier {
return entry.RemovedAt, true
}
if strings.TrimSpace(entry.Hostname) == hostname {
if hostAgentHostnamesMatch(entry.Hostname, hostname) {
return entry.RemovedAt, true
}
}
@ -735,6 +742,51 @@ func (m *Monitor) hostContinuitySince(now time.Time) time.Time {
return now.Add(-hostContinuityRetention)
}
func hostTokenBindingKey(tokenID, hostname string) string {
tokenID = strings.TrimSpace(tokenID)
hostname = strings.TrimSpace(hostname)
if tokenID == "" || hostname == "" {
return ""
}
return fmt.Sprintf("%s:%s", tokenID, hostname)
}
func lookupHostTokenBinding(bindings map[string]string, tokenID, hostname string) string {
if len(bindings) == 0 {
return ""
}
bindingKey := hostTokenBindingKey(tokenID, hostname)
if bindingKey == "" {
return ""
}
if boundID := strings.TrimSpace(bindings[bindingKey]); boundID != "" {
return boundID
}
prefix := strings.TrimSpace(tokenID) + ":"
for key, boundID := range bindings {
boundID = strings.TrimSpace(boundID)
if boundID == "" || !strings.HasPrefix(key, prefix) {
continue
}
boundHostname := strings.TrimSpace(strings.TrimPrefix(key, prefix))
if hostAgentHostnamesMatch(boundHostname, hostname) {
return boundID
}
}
return ""
}
func hostAgentHostnamesMatch(left, right string) bool {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
if left == "" || right == "" {
return false
}
return strings.EqualFold(left, right) || unifiedresources.HostnamesEquivalent(left, right)
}
func (m *Monitor) matchPersistedHostContinuity(
report agentshost.Report,
tokenRecord *config.APITokenRecord,
@ -909,7 +961,7 @@ func (m *Monitor) RebuildTokenBindings() {
if hostname == "" || agentID == "" {
continue
}
newHostBindings[fmt.Sprintf("%s:%s", tokenID, hostname)] = agentID
newHostBindings[hostTokenBindingKey(tokenID, hostname)] = agentID
}
// Log what changed
@ -1613,13 +1665,16 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
identifier := baseIdentifier
if tokenRecord != nil && strings.TrimSpace(tokenRecord.ID) != "" {
tokenID := strings.TrimSpace(tokenRecord.ID)
bindingKey := fmt.Sprintf("%s:%s", tokenID, hostname)
bindingKey := hostTokenBindingKey(tokenID, hostname)
m.mu.Lock()
if m.hostTokenBindings == nil {
m.hostTokenBindings = make(map[string]string)
}
boundID := strings.TrimSpace(m.hostTokenBindings[bindingKey])
boundID := lookupHostTokenBinding(m.hostTokenBindings, tokenID, hostname)
if boundID != "" {
m.hostTokenBindings[bindingKey] = boundID
}
m.mu.Unlock()
// If we already have a binding for this token+hostname, use it to keep host IDs stable
@ -1632,7 +1687,7 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
if candidate == nil || candidate.AgentID() != bindingID {
continue
}
if strings.TrimSpace(candidate.Hostname()) == hostname && strings.TrimSpace(candidate.TokenID()) == tokenID {
if hostAgentHostnamesMatch(candidate.Hostname(), hostname) && strings.TrimSpace(candidate.TokenID()) == tokenID {
break
}
@ -1655,7 +1710,8 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
if m.hostTokenBindings == nil {
m.hostTokenBindings = make(map[string]string)
}
if existing := strings.TrimSpace(m.hostTokenBindings[bindingKey]); existing != "" {
if existing := lookupHostTokenBinding(m.hostTokenBindings, tokenID, hostname); existing != "" {
m.hostTokenBindings[bindingKey] = existing
identifier = existing
} else {
m.hostTokenBindings[bindingKey] = bindingID

View file

@ -657,6 +657,62 @@ func TestApplyHostReportReusesPersistedContinuityAcrossRestart(t *testing.T) {
}
}
func TestApplyHostReportReusesTokenBindingAcrossShortFQDNAfterReload(t *testing.T) {
t.Helper()
monitor := &Monitor{
state: models.NewState(),
alertManager: alerts.NewManager(),
hostTokenBindings: make(map[string]string),
config: &config.Config{},
rateTracker: NewRateTracker(),
}
t.Cleanup(func() { monitor.alertManager.Stop() })
now := time.Now().UTC()
token := &config.APITokenRecord{ID: "token-host-continuity", Name: "Host Token"}
monitor.state.UpsertHost(models.Host{
ID: "host-v5",
Hostname: "docker-lxc.lab",
DisplayName: "docker-lxc",
TokenID: token.ID,
Status: "online",
AgentVersion: "5.1.30",
IntervalSeconds: 30,
LastSeen: now.Add(-time.Minute),
})
monitor.hostTokenBindings[token.ID+":docker-lxc.lab"] = "host-v5"
report := agentshost.Report{
Agent: agentshost.AgentInfo{
Version: "6.0.0-rc.4",
IntervalSeconds: 30,
},
Host: agentshost.HostInfo{
Hostname: "docker-lxc",
Platform: "linux",
OSName: "debian",
},
Timestamp: now,
}
host, err := monitor.ApplyHostReport(report, token)
if err != nil {
t.Fatalf("ApplyHostReport: %v", err)
}
if host.ID != "host-v5" {
t.Fatalf("expected equivalent hostname token binding to preserve host ID host-v5, got %q", host.ID)
}
snapshot := monitor.state.GetSnapshot()
if got := len(snapshot.Hosts); got != 1 {
t.Fatalf("expected host report to update existing host instead of creating duplicate, got %d hosts", got)
}
if got := monitor.hostTokenBindings[token.ID+":docker-lxc"]; got != "host-v5" {
t.Fatalf("expected current hostname alias to bind to host-v5, got %q", got)
}
}
func TestApplyHostReportDisambiguatesCollidingIdentifiersAcrossTokens(t *testing.T) {
t.Helper()
@ -759,6 +815,7 @@ func TestRemoveHostAgentUnbindsToken(t *testing.T) {
TokenID: tokenID,
})
monitor.hostTokenBindings[tokenID+":remove.me"] = hostID
monitor.hostTokenBindings[tokenID+":remove.me.local"] = hostID
monitor.hostTokenBindings[tokenID] = hostID
if _, err := monitor.RemoveHostAgent(hostID); err != nil {
@ -771,6 +828,9 @@ func TestRemoveHostAgentUnbindsToken(t *testing.T) {
if _, exists := monitor.hostTokenBindings[tokenID]; exists {
t.Fatalf("expected legacy token binding to be cleared after host removal")
}
if _, exists := monitor.hostTokenBindings[tokenID+":remove.me.local"]; exists {
t.Fatalf("expected equivalent hostname token binding to be cleared after host removal")
}
}
func TestRemoveHostAgent_PreservesLinkedGuestIdentityInRemovedState(t *testing.T) {