mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Keep non-AI route bootstrap off assistant APIs
This commit is contained in:
parent
1649faa75d
commit
96236994f6
22 changed files with 323 additions and 77 deletions
|
|
@ -206,6 +206,14 @@ management, and fleet control surfaces.
|
|||
entitlements payloads, but they must not cache their own pre-activation
|
||||
host counts, synthesize a second grandfather floor, or treat install-time
|
||||
fleet inventory as the authority for commercial continuity.
|
||||
14. Keep dormant assistant availability on the shared auth/bootstrap contract.
|
||||
When `internal/api/ai_handlers.go`,
|
||||
`internal/api/router_routes_auth_security.go`, or
|
||||
`internal/api/security_status_capabilities.go` change, first-session,
|
||||
setup, and install routes that do not own assistant or Patrol UI must
|
||||
consume `/api/security/status.sessionCapabilities.assistantEnabled`
|
||||
instead of probing `/api/settings/ai` or `/api/ai/*` during ordinary route
|
||||
bootstrap.
|
||||
|
||||
## Forbidden Paths
|
||||
|
||||
|
|
|
|||
|
|
@ -178,6 +178,14 @@ quickstart-ready installs may enable Patrol directly without opening provider
|
|||
setup, while activation-required or offline quickstart states must surface one
|
||||
canonical blocked reason and fall back to activation-or-BYOK guidance instead
|
||||
of inferring readiness from provider model catalogs or local credit counters.
|
||||
That same AI/runtime boundary now also owns the server-side assistant
|
||||
availability fact used by the app shell. `internal/api/ai_handlers.go`,
|
||||
`internal/api/security_status_capabilities.go`, and
|
||||
`internal/api/router_routes_auth_security.go` must expose one canonical
|
||||
`/api/security/status.sessionCapabilities.assistantEnabled` signal for the
|
||||
closed assistant affordance, so unrelated shells do not probe
|
||||
`/api/settings/ai` or `/api/ai/sessions` during ordinary route bootstrap just
|
||||
to decide whether the assistant drawer may be opened.
|
||||
That same settings/runtime boundary now also governs BYOK first-run setup:
|
||||
`frontend-modern/src/components/Settings/useAISettingsState.ts` may send only
|
||||
provider credentials or base URLs when the operator connects a provider, and
|
||||
|
|
|
|||
|
|
@ -477,6 +477,12 @@ as the canonical runtime contract for `demoMode`, `readOnly`,
|
|||
must therefore defer their first read until that policy has resolved, so
|
||||
public demos fail closed without probing hidden commercial routes during
|
||||
bootstrap.
|
||||
That same `/api/security/status` contract now also owns closed-shell
|
||||
assistant availability. The response must expose
|
||||
`sessionCapabilities.assistantEnabled` as the only general-route bootstrap
|
||||
fact for dormant assistant chrome, so non-AI routes do not probe
|
||||
`/api/settings/ai` or `/api/ai/*` merely to decide whether the assistant
|
||||
affordance may be opened.
|
||||
That same contract split also makes the licensing boundary explicit:
|
||||
`/api/license/runtime-capabilities` is the public runtime feature contract,
|
||||
`/api/license/commercial-posture` is the non-billing upgrade/trial posture
|
||||
|
|
|
|||
|
|
@ -135,6 +135,14 @@ agreement, and cloud-specific enforcement rules.
|
|||
`/settings/system/billing/usage?details=counting-rules` for explanation and
|
||||
`/settings/system/billing/plan?intent=max_monitored_systems` for upgrade
|
||||
intent.
|
||||
24. Keep dormant assistant availability on the shared browser bootstrap
|
||||
contract. When `frontend-modern/src/App.tsx`,
|
||||
`frontend-modern/src/useAppRuntimeState.ts`,
|
||||
`internal/api/router_routes_auth_security.go`, or
|
||||
`internal/api/security_status_capabilities.go` change, cloud-paid routes
|
||||
that do not own assistant or Patrol interaction must consume
|
||||
`/api/security/status.sessionCapabilities.assistantEnabled` instead of
|
||||
probing `/api/settings/ai` or `/api/ai/*` during ordinary route bootstrap.
|
||||
|
||||
## Forbidden Paths
|
||||
|
||||
|
|
|
|||
|
|
@ -208,6 +208,16 @@ work extends shared components instead of creating new local variants.
|
|||
labels must resolve to distinct owned destinations or section anchors
|
||||
instead of presenting two different labels that land on the same
|
||||
unscoped billing screen.
|
||||
15. Keep assistant availability bootstrap on the shared app-shell boundary.
|
||||
`frontend-modern/src/useAppRuntimeState.ts`,
|
||||
`frontend-modern/src/App.tsx`,
|
||||
`frontend-modern/src/components/AI/Chat/index.tsx`, and
|
||||
`frontend-modern/src/hooks/useDashboardActions.ts` must consume the
|
||||
backend-owned `/api/security/status.sessionCapabilities.assistantEnabled`
|
||||
fact instead of probing `/api/settings/ai` or `/api/ai/*` during ordinary
|
||||
route bootstrap. Closed assistant chrome and non-AI settings panels may
|
||||
not initialize assistant runtime state until an owned assistant or Patrol
|
||||
surface is actually open.
|
||||
|
||||
## Forbidden Paths
|
||||
|
||||
|
|
@ -1912,3 +1922,15 @@ response-header inference, hostname heuristics, or per-banner demo branching;
|
|||
the runtime bootstrap, shared session-capability store, and shared banner
|
||||
hooks stay on one canonical owner so suppression stays coherent across
|
||||
customer-facing surfaces.
|
||||
That same shared app-shell boundary now also owns assistant bootstrap silence
|
||||
on non-AI routes. `frontend-modern/src/useAppRuntimeState.ts`,
|
||||
`frontend-modern/src/App.tsx`,
|
||||
`frontend-modern/src/components/AI/Chat/index.tsx`, and
|
||||
`frontend-modern/src/hooks/useDashboardActions.ts` must treat
|
||||
`/api/security/status.sessionCapabilities.assistantEnabled` as the only
|
||||
general-route assistant availability fact, while closed assistant chrome and
|
||||
non-AI settings panels stay off `/api/settings/ai` and `/api/ai/*` until an
|
||||
owned assistant or Patrol surface is actually open. The governed browser
|
||||
proof in `tests/integration/tests/11-first-session.spec.ts` must continue to
|
||||
assert that plain settings routes render without assistant bootstrap traffic
|
||||
or console noise.
|
||||
|
|
|
|||
|
|
@ -390,7 +390,13 @@ That same auth/security boundary also owns browser session-capability posture:
|
|||
`internal/api/security_status_capabilities.go` must expose
|
||||
`/api/security/status.sessionCapabilities.demoMode` as the backend-owned
|
||||
public-demo posture signal, and security/privacy consumers must not infer demo
|
||||
state from response headers, `/api/health`, or hostname heuristics.
|
||||
state from response headers, `/api/health`, or hostname heuristics. That same
|
||||
session-capability contract now also carries the closed-shell assistant
|
||||
availability fact through
|
||||
`/api/security/status.sessionCapabilities.assistantEnabled`, so general
|
||||
settings or security surfaces do not probe `/api/settings/ai` or other
|
||||
assistant endpoints merely to decide whether dormant assistant chrome may be
|
||||
opened.
|
||||
That same token-management boundary now also depends on one neutral
|
||||
app-runtime context owner. `frontend-modern/src/components/Settings/useAPITokenManagerState.ts`
|
||||
may consume websocket-backed revocation fan-out through
|
||||
|
|
|
|||
|
|
@ -176,6 +176,14 @@ querying, and the operator-facing storage health presentation layer.
|
|||
backup counts, or recovery-point presence when commercial continuity is
|
||||
already defined by the canonical monitored-system resolver and activation
|
||||
persistence.
|
||||
37. Keep dormant assistant availability on the shared auth/bootstrap contract.
|
||||
When `frontend-modern/src/App.tsx`, `internal/api/ai_handlers.go`,
|
||||
`internal/api/router_routes_auth_security.go`, or
|
||||
`internal/api/security_status_capabilities.go` change, storage and
|
||||
recovery routes that do not own assistant or Patrol interaction must
|
||||
consume `/api/security/status.sessionCapabilities.assistantEnabled`
|
||||
instead of probing `/api/settings/ai` or `/api/ai/*` during ordinary route
|
||||
bootstrap.
|
||||
|
||||
## Forbidden Paths
|
||||
|
||||
|
|
|
|||
|
|
@ -296,26 +296,8 @@ function App() {
|
|||
onCloseCommandPalette: () => setCommandPaletteOpen(false),
|
||||
});
|
||||
|
||||
// Check AI settings on mount and setup escape handling
|
||||
// Setup escape handling for the assistant drawer.
|
||||
onMount(() => {
|
||||
// Only check AI settings if already authenticated (not on login screen)
|
||||
// Otherwise, the 401 response triggers a redirect loop
|
||||
if (!runtime.needsAuth()) {
|
||||
import('./api/ai').then(({ AIAPI }) => {
|
||||
AIAPI.getSettings()
|
||||
.then((settings) => {
|
||||
aiChatStore.setEnabled(settings.enabled && settings.configured);
|
||||
// Initialize chat session sync with server
|
||||
if (settings.enabled && settings.configured) {
|
||||
aiChatStore.initSync();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
aiChatStore.setEnabled(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Escape to close
|
||||
if (e.key === 'Escape' && aiChatStore.isOpen) {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ describe('App architecture', () => {
|
|||
expect(appSource).not.toContain('const [organizations, setOrganizations] = createSignal(');
|
||||
expect(appSource).not.toContain('const [themePreference, setThemePreference] =');
|
||||
expect(appSource).not.toContain('const [activeOrgID, setActiveOrgID] = createSignal(');
|
||||
expect(appSource).not.toContain("import('./api/ai')");
|
||||
expect(appSource).not.toContain('AIAPI.getSettings()');
|
||||
});
|
||||
|
||||
it('keeps authenticated chrome in AppLayout and hosted bootstrap in useAppRuntimeState', () => {
|
||||
|
|
@ -82,6 +84,7 @@ describe('App architecture', () => {
|
|||
expect(appRuntimeStateSource).toContain('onMount(() => {');
|
||||
expect(appRuntimeStateSource).toContain('onMount(async () => {');
|
||||
expect(appRuntimeStateSource).toContain('void loadCommercialPosture();');
|
||||
expect(appRuntimeStateSource).toContain('aiChatStore.setEnabled(');
|
||||
expect(appRuntimeStateSource).toContain("eventBus.on('theme_changed', handleRemoteThemeChange);");
|
||||
expect(appRuntimeStateSource).toContain(
|
||||
"eventBus.on('websocket_reconnected', handleWebSocketReconnected);",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ describe('useAppRuntimeState', () => {
|
|||
let getOrgIDMock: ReturnType<typeof vi.fn>;
|
||||
let setOrgIDMock: ReturnType<typeof vi.fn>;
|
||||
let showToastMock: ReturnType<typeof vi.fn>;
|
||||
let aiChatSetEnabledMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
|
|
@ -67,6 +68,7 @@ describe('useAppRuntimeState', () => {
|
|||
getOrgIDMock = vi.fn().mockReturnValue('default');
|
||||
setOrgIDMock = vi.fn();
|
||||
showToastMock = vi.fn();
|
||||
aiChatSetEnabledMock = vi.fn();
|
||||
|
||||
vi.doMock('@/stores/websocket-global', () => ({
|
||||
getGlobalWebSocketStore: () => ({
|
||||
|
|
@ -203,6 +205,12 @@ describe('useAppRuntimeState', () => {
|
|||
loadRuntimeCapabilities: loadLicenseStatusMock,
|
||||
}));
|
||||
|
||||
vi.doMock('@/stores/aiChat', () => ({
|
||||
aiChatStore: {
|
||||
setEnabled: aiChatSetEnabledMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.doMock('@/utils/layout', () => ({
|
||||
layoutStore: {
|
||||
loadFromServer: vi.fn(),
|
||||
|
|
@ -277,7 +285,7 @@ describe('useAppRuntimeState', () => {
|
|||
return new Response(
|
||||
JSON.stringify({
|
||||
hasAuthentication: true,
|
||||
sessionCapabilities: { demoMode: true },
|
||||
sessionCapabilities: { demoMode: true, assistantEnabled: true },
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
|
|
@ -296,6 +304,7 @@ describe('useAppRuntimeState', () => {
|
|||
|
||||
expect(demoModeModule.demoModeResolved()).toBe(true);
|
||||
expect(demoModeModule.demoModeEnabled()).toBe(true);
|
||||
expect(aiChatSetEnabledMock).toHaveBeenCalledWith(true);
|
||||
|
||||
dispose();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ describe('SecurityAPI', () => {
|
|||
exportProtected: true,
|
||||
hasAuditLogging: true,
|
||||
configuredButPendingRestart: false,
|
||||
sessionCapabilities: {
|
||||
demoMode: false,
|
||||
assistantEnabled: true,
|
||||
},
|
||||
};
|
||||
vi.mocked(apiFetchJSON).mockResolvedValueOnce(mockStatus);
|
||||
|
||||
|
|
@ -31,6 +35,27 @@ describe('SecurityAPI', () => {
|
|||
expect(apiFetchJSON).toHaveBeenCalledWith('/api/security/status');
|
||||
expect(result).toEqual(mockStatus);
|
||||
});
|
||||
|
||||
it('preserves assistant availability in session capabilities', async () => {
|
||||
const mockStatus: SecurityStatus = {
|
||||
hasAuthentication: true,
|
||||
apiTokenConfigured: false,
|
||||
requiresAuth: true,
|
||||
credentialsEncrypted: true,
|
||||
exportProtected: true,
|
||||
hasAuditLogging: false,
|
||||
configuredButPendingRestart: false,
|
||||
sessionCapabilities: {
|
||||
demoMode: false,
|
||||
assistantEnabled: true,
|
||||
},
|
||||
};
|
||||
vi.mocked(apiFetchJSON).mockResolvedValueOnce(mockStatus);
|
||||
|
||||
const result = await SecurityAPI.getStatus();
|
||||
|
||||
expect(result.sessionCapabilities?.assistantEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listTokens', () => {
|
||||
|
|
|
|||
|
|
@ -286,6 +286,19 @@ describe('AIChat', () => {
|
|||
renderChat();
|
||||
expect(screen.queryByText('Pulse Assistant')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not initialize sessions or models while closed', async () => {
|
||||
mockAiChatStore.isOpenSignal.mockReturnValue(false);
|
||||
renderChat();
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mockAIChatAPI.getStatus).not.toHaveBeenCalled();
|
||||
expect(mockAIChatAPI.listSessions).not.toHaveBeenCalled();
|
||||
expect(mockAIAPI.getSettings).not.toHaveBeenCalled();
|
||||
expect(mockAIAPI.getModels).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Input & submit ───────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -246,9 +246,31 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
}
|
||||
});
|
||||
|
||||
const initializeWhenOpen = async () => {
|
||||
try {
|
||||
const status = await AIChatAPI.getStatus();
|
||||
if (!status.running) {
|
||||
// AI not running - silently clear dynamic state instead of logging on
|
||||
// unrelated routes or after the assistant is disabled.
|
||||
setSessions([]);
|
||||
setModels([]);
|
||||
setModelsError('');
|
||||
return;
|
||||
}
|
||||
await refreshSessions();
|
||||
await loadSettings();
|
||||
await loadModels();
|
||||
} catch (error) {
|
||||
logger.error('[AIChat] Failed to initialize:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh models when AI settings change (e.g., new API key added)
|
||||
createEffect(() => {
|
||||
const version = aiChatStore.settingsVersionSignal();
|
||||
if (!isOpen()) {
|
||||
return;
|
||||
}
|
||||
// Skip the initial run (version 0)
|
||||
if (version > 0) {
|
||||
loadModels();
|
||||
|
|
@ -290,21 +312,11 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
return { type: 'thinking', text: 'Thinking...' };
|
||||
});
|
||||
|
||||
// Load sessions on mount
|
||||
onMount(async () => {
|
||||
try {
|
||||
const status = await AIChatAPI.getStatus();
|
||||
if (!status.running) {
|
||||
// AI not running - silently return, don't show warning on every page load
|
||||
// Users who intentionally disabled AI don't need a notification about it
|
||||
return;
|
||||
}
|
||||
await refreshSessions();
|
||||
await loadSettings();
|
||||
await loadModels();
|
||||
} catch (error) {
|
||||
logger.error('[AIChat] Failed to initialize:', error);
|
||||
createEffect(() => {
|
||||
if (!isOpen()) {
|
||||
return;
|
||||
}
|
||||
void initializeWhenOpen();
|
||||
});
|
||||
|
||||
// Click outside handler to close all dropdowns
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ describe('useDashboardActions', () => {
|
|||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-03-01T00:00:00Z'));
|
||||
window.history.replaceState({}, '', '/dashboard');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -164,4 +165,26 @@ describe('useDashboardActions', () => {
|
|||
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('stays idle when the dashboard action surface is not enabled', async () => {
|
||||
let dispose!: () => void;
|
||||
let actions!: ReturnType<typeof useDashboardActions>;
|
||||
|
||||
createRoot((d) => {
|
||||
dispose = d;
|
||||
const [alertsList] = createSignal<Alert[]>([]);
|
||||
const [enabled] = createSignal(false);
|
||||
actions = useDashboardActions(alertsList, { enabled });
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(vi.mocked(AIAPI.getUnifiedFindings)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(AIAPI.getPendingApprovals)).not.toHaveBeenCalled();
|
||||
expect(actions.pendingApprovals()).toEqual([]);
|
||||
expect(actions.findingsNeedingAttention()).toEqual([]);
|
||||
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { UnifiedFinding } from '@/stores/aiIntelligence';
|
|||
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
|
||||
import type { ApprovalRequest } from '@/api/ai';
|
||||
import { hasFeature } from '@/stores/license';
|
||||
import { DASHBOARD_PATH } from '@/routing/resourceLinks';
|
||||
|
||||
export interface DashboardActions {
|
||||
pendingApprovals: Accessor<ApprovalRequest[]>;
|
||||
|
|
@ -13,16 +14,26 @@ export interface DashboardActions {
|
|||
totalActionCount: Accessor<number>;
|
||||
}
|
||||
|
||||
interface UseDashboardActionsOptions {
|
||||
enabled?: Accessor<boolean>;
|
||||
}
|
||||
|
||||
const APPROVAL_REFRESH_INTERVAL_MS = 30_000;
|
||||
|
||||
export function useDashboardActions(alertsList: Accessor<Alert[]>): DashboardActions {
|
||||
const hasPatrol = () => hasFeature('ai_patrol');
|
||||
export function useDashboardActions(
|
||||
alertsList: Accessor<Alert[]>,
|
||||
options: UseDashboardActionsOptions = {},
|
||||
): DashboardActions {
|
||||
const enabled = () =>
|
||||
options.enabled?.() ??
|
||||
(typeof window === 'undefined' || window.location.pathname === DASHBOARD_PATH);
|
||||
const hasPatrol = () => enabled() && hasFeature('ai_patrol');
|
||||
|
||||
// Load patrol data on mount when feature is enabled
|
||||
createEffect(() => {
|
||||
if (hasPatrol()) {
|
||||
aiIntelligenceStore.loadFindings();
|
||||
aiIntelligenceStore.loadPendingApprovals();
|
||||
void aiIntelligenceStore.loadFindings();
|
||||
void aiIntelligenceStore.loadPendingApprovals();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -35,7 +46,7 @@ export function useDashboardActions(alertsList: Accessor<Alert[]>): DashboardAct
|
|||
}
|
||||
if (hasPatrol()) {
|
||||
refreshInterval = window.setInterval(() => {
|
||||
aiIntelligenceStore.loadPendingApprovals();
|
||||
void aiIntelligenceStore.loadPendingApprovals();
|
||||
}, APPROVAL_REFRESH_INTERVAL_MS);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ export interface SecurityStatusSettingsCapabilities {
|
|||
|
||||
export interface SecurityStatusSessionCapabilities {
|
||||
demoMode: boolean;
|
||||
assistantEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SecurityStatusPresentationPolicy {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import {
|
|||
runtimeCapabilitiesLoaded,
|
||||
loadRuntimeCapabilities,
|
||||
} from '@/stores/license';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { loadCommercialPosture } from '@/stores/licenseCommercial';
|
||||
import { syncSessionPresentationPolicy } from '@/stores/sessionPresentationPolicy';
|
||||
import { layoutStore } from '@/utils/layout';
|
||||
|
|
@ -276,6 +277,14 @@ export const useAppRuntimeState = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const applySecurityStatus = (securityData: SecurityStatus | null) => {
|
||||
if (securityData) {
|
||||
syncSessionPresentationPolicy(securityData);
|
||||
}
|
||||
setSecurityStatus(securityData);
|
||||
aiChatStore.setEnabled(securityData?.sessionCapabilities?.assistantEnabled === true);
|
||||
};
|
||||
|
||||
const beginAuthenticatedRuntime = async () => {
|
||||
setNeedsAuth(false);
|
||||
await loadOrganizations();
|
||||
|
|
@ -572,6 +581,7 @@ export const useAppRuntimeState = () => {
|
|||
} catch (clearError) {
|
||||
logger.warn('[App] Failed to clear stored auth after 401', clearError);
|
||||
}
|
||||
aiChatStore.setEnabled(false);
|
||||
setHasAuth(false);
|
||||
setNeedsAuth(true);
|
||||
setIsLoading(false);
|
||||
|
|
@ -583,8 +593,7 @@ export const useAppRuntimeState = () => {
|
|||
logger.debug('[App] User logged out, showing login page');
|
||||
if (securityResponse.ok) {
|
||||
const securityData = (await securityResponse.json()) as SecurityStatus;
|
||||
syncSessionPresentationPolicy(securityData);
|
||||
setSecurityStatus(securityData);
|
||||
applySecurityStatus(securityData);
|
||||
}
|
||||
setHasAuth(true);
|
||||
setNeedsAuth(true);
|
||||
|
|
@ -606,8 +615,7 @@ export const useAppRuntimeState = () => {
|
|||
ssoLogoutURL?: string;
|
||||
};
|
||||
logger.debug('[App] Security status fetched', securityData);
|
||||
syncSessionPresentationPolicy(securityData);
|
||||
setSecurityStatus(securityData);
|
||||
applySecurityStatus(securityData);
|
||||
|
||||
const authConfigured = securityData.hasAuthentication || false;
|
||||
setHasAuth(authConfigured);
|
||||
|
|
@ -664,6 +672,7 @@ export const useAppRuntimeState = () => {
|
|||
} catch (clearError) {
|
||||
logger.warn('[App] Failed to clear stored auth after auth check error', clearError);
|
||||
}
|
||||
aiChatStore.setEnabled(false);
|
||||
setHasAuth(false);
|
||||
setNeedsAuth(true);
|
||||
} finally {
|
||||
|
|
@ -705,6 +714,7 @@ export const useAppRuntimeState = () => {
|
|||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
sessionStorage.clear();
|
||||
localStorage.setItem('just_logged_out', 'true');
|
||||
aiChatStore.setEnabled(false);
|
||||
|
||||
if (wsStore()) {
|
||||
setWsStore(null);
|
||||
|
|
|
|||
|
|
@ -2314,6 +2314,38 @@ func (h *AISettingsHandler) populateQuickstartFields(ctx context.Context, resp *
|
|||
}
|
||||
}
|
||||
|
||||
// AssistantEnabled reports whether the Pulse Assistant affordance should be
|
||||
// shown in the authenticated shell without forcing the browser to probe the
|
||||
// full AI settings API on every route bootstrap.
|
||||
func (h *AISettingsHandler) AssistantEnabled(ctx context.Context) bool {
|
||||
if h == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
settings, err := h.loadAIConfig(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if settings == nil {
|
||||
settings = config.NewDefaultAIConfig()
|
||||
}
|
||||
|
||||
enabled := settings.Enabled || mockmode.IsEnabled()
|
||||
if !enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
if settings.IsConfigured() || mockmode.IsEnabled() {
|
||||
return true
|
||||
}
|
||||
|
||||
response := AISettingsResponse{
|
||||
ConfiguredProviders: settings.GetConfiguredProviders(),
|
||||
}.NormalizeCollections()
|
||||
h.populateQuickstartFields(ctx, &response)
|
||||
return response.QuickstartCreditsAvailable
|
||||
}
|
||||
|
||||
func aiSettingsRequireModelResolution(settings *config.AIConfig) bool {
|
||||
if settings == nil || !settings.IsConfigured() {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@ func (r *Router) registerAuthSecurityInstallRoutes() {
|
|||
"ssoSessionUsername": ssoSessionUsername,
|
||||
"hideLocalLogin": r.config.HideLocalLogin,
|
||||
"agentUrl": agentURL,
|
||||
"sessionCapabilities": r.securityStatusSessionCapabilities(),
|
||||
"sessionCapabilities": r.securityStatusSessionCapabilities(req.Context()),
|
||||
"presentationPolicy": r.securityStatusPresentationPolicy(),
|
||||
"settingsCapabilities": r.securityStatusSettingsCapabilitiesFromSnapshot(authSnapshot),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -410,6 +410,9 @@ func TestSecurityStatusIncludesDemoModeSessionCapabilities(t *testing.T) {
|
|||
if !payload.SessionCapabilities.DemoMode {
|
||||
t.Fatalf("expected sessionCapabilities.demoMode to be true, got %#v", payload.SessionCapabilities)
|
||||
}
|
||||
if payload.SessionCapabilities.AssistantEnabled {
|
||||
t.Fatalf("expected sessionCapabilities.assistantEnabled to default false, got %#v", payload.SessionCapabilities)
|
||||
}
|
||||
if !payload.PresentationPolicy.DemoMode ||
|
||||
!payload.PresentationPolicy.ReadOnly ||
|
||||
!payload.PresentationPolicy.HideCommercial ||
|
||||
|
|
@ -417,3 +420,39 @@ func TestSecurityStatusIncludesDemoModeSessionCapabilities(t *testing.T) {
|
|||
t.Fatalf("expected demo presentation policy, got %#v", payload.PresentationPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityStatusIncludesAssistantAvailability(t *testing.T) {
|
||||
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
||||
cfg := &config.Config{}
|
||||
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
||||
|
||||
persistence := router.aiSettingsHandler.getPersistence(context.Background())
|
||||
if persistence == nil {
|
||||
t.Fatal("expected AI persistence for security status test")
|
||||
}
|
||||
if err := persistence.SaveAIConfig(config.AIConfig{
|
||||
Enabled: true,
|
||||
OpenAIAPIKey: "sk-test",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveAIConfig(): %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for security status, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
SessionCapabilities securityStatusSessionCapabilities `json:"sessionCapabilities"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if !payload.SessionCapabilities.AssistantEnabled {
|
||||
t.Fatalf("expected sessionCapabilities.assistantEnabled to be true, got %#v", payload.SessionCapabilities)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
|
|
@ -26,7 +27,8 @@ type securityStatusSettingsCapabilities struct {
|
|||
}
|
||||
|
||||
type securityStatusSessionCapabilities struct {
|
||||
DemoMode bool `json:"demoMode"`
|
||||
DemoMode bool `json:"demoMode"`
|
||||
AssistantEnabled bool `json:"assistantEnabled"`
|
||||
}
|
||||
|
||||
type securityStatusPresentationPolicy struct {
|
||||
|
|
@ -238,10 +240,15 @@ func (r *Router) securityStatusSettingsCapabilities(req *http.Request) securityS
|
|||
return r.securityStatusSettingsCapabilitiesFromSnapshot(r.buildSecurityStatusAuthSnapshot(req))
|
||||
}
|
||||
|
||||
func (r *Router) securityStatusSessionCapabilities() securityStatusSessionCapabilities {
|
||||
func (r *Router) securityStatusSessionCapabilities(ctx context.Context) securityStatusSessionCapabilities {
|
||||
demoMode := r != nil && r.config != nil && r.config.DemoMode
|
||||
assistantEnabled := false
|
||||
if r != nil && r.aiSettingsHandler != nil {
|
||||
assistantEnabled = r.aiSettingsHandler.AssistantEnabled(ctx)
|
||||
}
|
||||
return securityStatusSessionCapabilities{
|
||||
DemoMode: demoMode,
|
||||
DemoMode: demoMode,
|
||||
AssistantEnabled: assistantEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -115,45 +115,58 @@ test.describe.serial('First-session experience', () => {
|
|||
] as const;
|
||||
|
||||
const consoleErrors: string[] = [];
|
||||
const aiRequests = trackBrowserRequests(page, /\/api\/ai(\/|$)/);
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
for (const route of keyRoutes) {
|
||||
consoleErrors.length = 0;
|
||||
try {
|
||||
for (const route of keyRoutes) {
|
||||
consoleErrors.length = 0;
|
||||
aiRequests.clear();
|
||||
|
||||
await page.goto(route, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL(/\/settings/, { timeout: 10_000 });
|
||||
await expect(page.locator('#root')).toBeVisible();
|
||||
await page.goto(route, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL(/\/settings/, { timeout: 10_000 });
|
||||
await expect(page.locator('#root')).toBeVisible();
|
||||
|
||||
// Wait for the panel content to render. Scope to the settings content
|
||||
// area (everything after the sidebar) to avoid matching sidebar labels.
|
||||
// Fall back to a page-wide heading if the content area locator doesn't
|
||||
// match — some layouts render headings differently.
|
||||
const panelHeading = page.locator('h1, h2, h3, [role="heading"]')
|
||||
.filter({ hasNotText: 'Settings' })
|
||||
.first();
|
||||
await expect(
|
||||
panelHeading,
|
||||
`Route ${route} did not render any panel content`,
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
// Wait for the panel content to render. Scope to the settings content
|
||||
// area (everything after the sidebar) to avoid matching sidebar labels.
|
||||
// Fall back to a page-wide heading if the content area locator doesn't
|
||||
// match — some layouts render headings differently.
|
||||
const panelHeading = page.locator('h1, h2, h3, [role="heading"]')
|
||||
.filter({ hasNotText: 'Settings' })
|
||||
.first();
|
||||
await expect(
|
||||
panelHeading,
|
||||
`Route ${route} did not render any panel content`,
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Filter out benign console noise.
|
||||
const realErrors = consoleErrors.filter(
|
||||
(e) =>
|
||||
!e.includes('Download the React DevTools') &&
|
||||
!e.includes('Warning:') &&
|
||||
!e.includes('favicon') &&
|
||||
!e.includes('ERR_CONNECTION_REFUSED') &&
|
||||
!e.includes('net::'),
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
expect(
|
||||
realErrors.length,
|
||||
`Unexpected console errors on ${route}: ${realErrors.join('; ')}`,
|
||||
).toBe(0);
|
||||
expect(
|
||||
aiRequests.count(),
|
||||
`Non-AI settings route ${route} should not bootstrap AI endpoints: ${aiRequests.urls().join(', ')}`,
|
||||
).toBe(0);
|
||||
|
||||
// Filter out benign console noise.
|
||||
const realErrors = consoleErrors.filter(
|
||||
(e) =>
|
||||
!e.includes('Download the React DevTools') &&
|
||||
!e.includes('Warning:') &&
|
||||
!e.includes('favicon') &&
|
||||
!e.includes('ERR_CONNECTION_REFUSED') &&
|
||||
!e.includes('net::'),
|
||||
);
|
||||
|
||||
expect(
|
||||
realErrors.length,
|
||||
`Unexpected console errors on ${route}: ${realErrors.join('; ')}`,
|
||||
).toBe(0);
|
||||
}
|
||||
} finally {
|
||||
aiRequests.stop();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue