fix(desktop): settings scope requests can never target primary by accident
The settings 'Applies to' store uses null = 'follow the active profile', but the API helpers (profileScoped/capabilityScoped) use null = 'target the primary/default backend'. Every page that passed the raw override to a request silently read/wrote the primary profile whenever no override was set — writes landed on the right profile via other paths while reads repainted primary's values, so profile model changes appeared to revert (#90549 class). Close the class at the seam instead of per call site: - store/settings-scope: new $settingsRequestProfile computed — the request-shaped scope (string | undefined, never null). Documented as THE value to hand to API helpers. - config-settings, keys-settings, messaging: consume the request-shaped computed; ModelSettings/MemoryConnect/ProviderConfigPanel/ useEnvCredentials props narrowed to string | undefined so a primary-targeting null can no longer be plumbed through. - keys-settings site was a live third instance: getEnvVars(null) read primary's env store on non-default profiles. Regression tests: store computed shape, ModelSettings unscoped+scoped reads, KeysSettings unscoped fetch (all fail against the old behavior; sabotage-verified).
This commit is contained in:
@@ -28,7 +28,7 @@ import { normalize } from '@/lib/text'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $changeEventsAvailable, $pairingChangeTick, $platformsChangeTick } from '@/store/live-sync'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $settingsScopeOverride } from '@/store/settings-scope'
|
||||
import { $settingsRequestProfile } from '@/store/settings-scope'
|
||||
import { runGatewayRestart } from '@/store/system-actions'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
@@ -128,10 +128,9 @@ function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) {
|
||||
export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) {
|
||||
const { t } = useI18n()
|
||||
const m = t.messaging
|
||||
// Shared settings "Applies to" scope. At this layer `null` means "follow
|
||||
// the active profile", while the API helpers use `undefined` for that
|
||||
// contract; passing null would deliberately target the primary profile.
|
||||
const scopeProfile = useStore($settingsScopeOverride) ?? undefined
|
||||
// Shared settings "Applies to" scope, request-shaped (undefined → follow
|
||||
// the active profile; the API helpers treat null as "target primary").
|
||||
const scopeProfile = useStore($settingsRequestProfile)
|
||||
// Both save/toggle toasts offer the same one-click restart.
|
||||
const restartGatewayAction = { label: t.commandCenter.restartGateway, onClick: () => void runGatewayRestart() }
|
||||
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
|
||||
|
||||
@@ -24,7 +24,7 @@ import { $keepAwake, setKeepAwake } from '@/store/keep-awake'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { normalizeProfileKey } from '@/store/profile'
|
||||
import { repoDiscoveryPolicyFromConfig, repoDiscoveryPolicySignature, scanAndRecordRepos } from '@/store/projects'
|
||||
import { $settingsScopeOverride } from '@/store/settings-scope'
|
||||
import { $settingsRequestProfile } from '@/store/settings-scope'
|
||||
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { hermesConfigCacheWriter, useHermesConfigRecord } from '../hooks/use-config-record'
|
||||
@@ -58,7 +58,7 @@ export function ConfigSettings({
|
||||
// inner page per scope so every draft/seed/autosave ref resets wholesale
|
||||
// when the target profile changes — the same guarantee useOnProfileSwitch
|
||||
// provides for app-wide switches, without hand-clearing each piece.
|
||||
const scopeProfile = useStore($settingsScopeOverride)
|
||||
const scopeProfile = useStore($settingsRequestProfile)
|
||||
|
||||
return (
|
||||
<ConfigSettingsInner
|
||||
@@ -85,7 +85,7 @@ function ConfigSettingsInner({
|
||||
onMainModelChanged,
|
||||
importInputRef,
|
||||
scopeProfile
|
||||
}: ConfigSettingsProps & { scopeProfile: null | string }) {
|
||||
}: ConfigSettingsProps & { scopeProfile: string | undefined }) {
|
||||
const { t } = useI18n()
|
||||
const c = t.settings.config
|
||||
const keepAwake = useStore($keepAwake)
|
||||
@@ -108,7 +108,7 @@ function ConfigSettingsInner({
|
||||
// consumer); suffixed only for an explicit scope override.
|
||||
queryKey:
|
||||
scopeProfile == null ? ['hermes-config-schema'] : ['hermes-config-schema', normalizeProfileKey(scopeProfile)],
|
||||
queryFn: () => getHermesConfigSchema(scopeProfile ?? undefined),
|
||||
queryFn: () => getHermesConfigSchema(scopeProfile),
|
||||
staleTime: 5 * 60 * 1000
|
||||
})
|
||||
|
||||
@@ -147,7 +147,7 @@ function ConfigSettingsInner({
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
getElevenLabsVoices(scopeProfile ?? undefined)
|
||||
getElevenLabsVoices(scopeProfile)
|
||||
.then(result => {
|
||||
if (cancelled || !result.available) {
|
||||
return
|
||||
@@ -178,7 +178,7 @@ function ConfigSettingsInner({
|
||||
const t = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await saveHermesConfig(config, scopeProfile ?? undefined)
|
||||
const result = await saveHermesConfig(config, scopeProfile)
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(c.autosaveFailed)
|
||||
|
||||
@@ -43,8 +43,10 @@ export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHe
|
||||
// credential pages (Providers, Keys) share one source of truth and one set of
|
||||
// mutation handlers instead of duplicating the plumbing. An optional `profile`
|
||||
// targets another profile's env store (the shared settings "Applies to"
|
||||
// scope); undefined/null keeps the app-wide active profile.
|
||||
export function useEnvCredentials(profile: null | string = null): UseEnvCredentials {
|
||||
// scope); undefined keeps the app-wide active profile. Request-shaped on
|
||||
// purpose: the API helpers treat an explicit `null` as "target the
|
||||
// primary/default backend", which is never what a settings page means.
|
||||
export function useEnvCredentials(profile?: string): UseEnvCredentials {
|
||||
const { t } = useI18n()
|
||||
const credentials = t.settings.credentials
|
||||
const toolsets = t.settings.toolsets
|
||||
|
||||
@@ -12,7 +12,7 @@ stubResizeObserver()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
deleteEnvVar: vi.fn(),
|
||||
getEnvVars: () => getEnvVars(),
|
||||
getEnvVars: (profile?: null | string) => getEnvVars(profile),
|
||||
revealEnvVar: vi.fn(),
|
||||
setApiRequestProfile: () => undefined,
|
||||
setEnvVar: vi.fn()
|
||||
@@ -54,6 +54,15 @@ function DeepLinkButton({ target }: { target: string }) {
|
||||
}
|
||||
|
||||
describe('KeysSettings', () => {
|
||||
it('fetches env vars for the active profile (undefined, never null) when unscoped', async () => {
|
||||
// #90549 class: getEnvVars(null) targets the primary profile's env store,
|
||||
// so a non-default profile's Keys page would read (and edit) the wrong
|
||||
// profile. Unscoped must send undefined so the active profile applies.
|
||||
await renderKeysSettings('tools')
|
||||
|
||||
await waitFor(() => expect(getEnvVars).toHaveBeenCalledWith(undefined))
|
||||
})
|
||||
|
||||
it('lists tools and excludes settings / channel-managed credentials', async () => {
|
||||
getEnvVars.mockResolvedValue({
|
||||
BRAVE_SEARCH_API_KEY: envVar('tool', { description: 'Search the web with Brave.' }),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { $settingsScopeOverride } from '@/store/settings-scope'
|
||||
import { $settingsRequestProfile } from '@/store/settings-scope'
|
||||
|
||||
import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui'
|
||||
import { useEnvCredentials } from './env-credentials'
|
||||
@@ -35,8 +35,10 @@ const credentialElementId = (key: string) => `credential-key-${key}`
|
||||
export function KeysSettings({ view }: KeysSettingsProps) {
|
||||
const { t } = useI18n()
|
||||
// Shared settings "Applies to" scope: fetch + edit the selected profile's
|
||||
// env store instead of the active one (null → active, the default path).
|
||||
const scopeProfile = useStore($settingsScopeOverride)
|
||||
// env store instead of the active one (undefined → active, the default
|
||||
// path — request-shaped so the API helpers never see a primary-targeting
|
||||
// null).
|
||||
const scopeProfile = useStore($settingsRequestProfile)
|
||||
const { rowProps, vars } = useEnvCredentials(scopeProfile)
|
||||
const [openKey, setOpenKey] = useState<null | string>(null)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const POLL_TIMEOUT_MS = 120_000
|
||||
// Small connect affordance rendered under the provider dropdown. Capability is
|
||||
// backend-driven: the status route 404s for providers without an oauth_flow
|
||||
// module, so non-OAuth providers render nothing.
|
||||
export function MemoryConnect({ profile = null, provider }: { profile?: null | string; provider: string }) {
|
||||
export function MemoryConnect({ profile, provider }: { profile?: string; provider: string }) {
|
||||
const [capable, setCapable] = useState<'no' | 'unknown' | 'yes'>('unknown')
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [auth, setAuth] = useState<MemoryProviderOAuthStatus['auth']>(null)
|
||||
|
||||
@@ -20,7 +20,7 @@ function seedValues(config: MemoryProviderConfig): Record<string, string> {
|
||||
)
|
||||
}
|
||||
|
||||
export function ProviderConfigPanel({ profile = null, provider }: { profile?: null | string; provider: string }) {
|
||||
export function ProviderConfigPanel({ profile, provider }: { profile?: string; provider: string }) {
|
||||
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
|
||||
const [loadError, setLoadError] = useState<null | string>(null)
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
|
||||
@@ -28,11 +28,12 @@ const startManualProviderOAuth = vi.fn()
|
||||
let profileSwitchHandler: (() => void) | null = null
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getGlobalModelInfo: () => getGlobalModelInfo(),
|
||||
getGlobalModelOptions: () => getGlobalModelOptions(),
|
||||
getAuxiliaryModels: () => getAuxiliaryModels(),
|
||||
getGlobalModelInfo: (profile?: null | string) => getGlobalModelInfo(profile),
|
||||
getGlobalModelOptions: (opts?: unknown, profile?: null | string) => getGlobalModelOptions(opts, profile),
|
||||
getAuxiliaryModels: (profile?: null | string) => getAuxiliaryModels(profile),
|
||||
getApiRequestProfile: () => 'default',
|
||||
getMoaModels: () => getMoaModels(),
|
||||
getMoaModels: (profile?: null | string) => getMoaModels(profile),
|
||||
profileScopeKey: (scope?: null | string) => (scope ?? '').trim() || 'default',
|
||||
setModelAssignment: (body: unknown) => setModelAssignment(body),
|
||||
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
|
||||
saveMoaModels: (body: unknown) => saveMoaModels(body),
|
||||
@@ -85,7 +86,7 @@ afterEach(() => {
|
||||
profileSwitchHandler = null
|
||||
})
|
||||
|
||||
async function renderModelSettings() {
|
||||
async function renderModelSettings(scopeProfile?: string) {
|
||||
const { ModelSettings } = await import('./model-settings')
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
@@ -94,12 +95,36 @@ async function renderModelSettings() {
|
||||
// needs a router context in tests (the app provides HashRouter at root).
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={client}>
|
||||
<ModelSettings />
|
||||
<ModelSettings scopeProfile={scopeProfile} />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ModelSettings profile scope', () => {
|
||||
// #90549: the API helpers treat `null` as "deliberately target the
|
||||
// primary/default profile". A page following the active profile must pass
|
||||
// `undefined`, or every read repaints the primary's model and the user's
|
||||
// change looks reverted.
|
||||
it('follows the active profile (undefined, never null) when unscoped', async () => {
|
||||
await renderModelSettings()
|
||||
|
||||
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalledWith(undefined))
|
||||
expect(getGlobalModelOptions).toHaveBeenCalledWith(undefined, undefined)
|
||||
expect(getAuxiliaryModels).toHaveBeenCalledWith(undefined)
|
||||
expect(getMoaModels).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('reads through the explicit scope override when one is set', async () => {
|
||||
await renderModelSettings('research')
|
||||
|
||||
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalledWith('research'))
|
||||
expect(getGlobalModelOptions).toHaveBeenCalledWith(undefined, 'research')
|
||||
expect(getAuxiliaryModels).toHaveBeenCalledWith('research')
|
||||
expect(getMoaModels).toHaveBeenCalledWith('research')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ModelSettings', () => {
|
||||
it('loads the current main model and lists configured providers only', async () => {
|
||||
await renderModelSettings()
|
||||
|
||||
@@ -182,17 +182,15 @@ interface ModelSettingsProps {
|
||||
/** Notified after the main model is applied, so live UI stores can sync. */
|
||||
onMainModelChanged?: (provider: string, model: string) => void
|
||||
/** Shared settings "Applies to" scope: a concrete profile to edit instead of
|
||||
* the app's active one, or null to follow the active profile (default). */
|
||||
scopeProfile?: null | string
|
||||
* the app's active one, or undefined to follow the active profile (default).
|
||||
* Request-shaped on purpose — the API helpers treat `null` as "deliberately
|
||||
* target the primary/default backend", so this prop never carries null. */
|
||||
scopeProfile?: string
|
||||
}
|
||||
|
||||
export function ModelSettings({ onMainModelChanged, scopeProfile = null }: ModelSettingsProps) {
|
||||
export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSettingsProps) {
|
||||
const { t } = useI18n()
|
||||
const m = t.settings.model
|
||||
// `null` means "follow the active profile" at the settings layer. The
|
||||
// Hermes API helpers use `undefined` for that contract; passing null would
|
||||
// deliberately suppress the active profile and read the primary/default.
|
||||
const requestProfile = scopeProfile ?? undefined
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
|
||||
@@ -239,10 +237,10 @@ export function ModelSettings({ onMainModelChanged, scopeProfile = null }: Model
|
||||
|
||||
try {
|
||||
const [modelInfo, modelOptions, auxiliaryModels, moaModels] = await Promise.all([
|
||||
getGlobalModelInfo(requestProfile),
|
||||
getGlobalModelOptions(undefined, requestProfile),
|
||||
getAuxiliaryModels(requestProfile),
|
||||
getMoaModels(requestProfile).catch(() => null)
|
||||
getGlobalModelInfo(scopeProfile),
|
||||
getGlobalModelOptions(undefined, scopeProfile),
|
||||
getAuxiliaryModels(scopeProfile),
|
||||
getMoaModels(scopeProfile).catch(() => null)
|
||||
])
|
||||
|
||||
if (profileEpoch.current !== epoch) {
|
||||
@@ -280,7 +278,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile = null }: Model
|
||||
}
|
||||
}
|
||||
},
|
||||
[requestProfile, scopeProfile]
|
||||
[scopeProfile]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -543,7 +541,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile = null }: Model
|
||||
setConfig(next)
|
||||
|
||||
try {
|
||||
await saveHermesConfig(next, scopeProfile ?? undefined)
|
||||
await saveHermesConfig(next, scopeProfile)
|
||||
} catch (err) {
|
||||
setConfig(prev)
|
||||
notifyError(err, m.defaultsFailed)
|
||||
|
||||
@@ -17,7 +17,7 @@ vi.mock('@/lib/query-client', () => ({ invalidateProfileScopedQueries: vi.fn() }
|
||||
vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() }))
|
||||
|
||||
const { $activeGatewayProfile } = await import('./profile')
|
||||
const { $settingsScopeOverride, $settingsScopeProfile, setSettingsScope } = await import('./settings-scope')
|
||||
const { $settingsRequestProfile, $settingsScopeOverride, $settingsScopeProfile, setSettingsScope } = await import('./settings-scope')
|
||||
|
||||
beforeEach(() => {
|
||||
$activeGatewayProfile.set('default')
|
||||
@@ -58,6 +58,18 @@ describe('settings scope store', () => {
|
||||
expect($settingsScopeProfile.get()).toBe('default')
|
||||
})
|
||||
|
||||
it('exposes a request-shaped scope: undefined (never null) without an override', () => {
|
||||
// api/client.ts profileScoped() treats null as "target primary/default" —
|
||||
// the #90549 bug class. The request form must therefore never be null.
|
||||
expect($settingsRequestProfile.get()).toBeUndefined()
|
||||
|
||||
setSettingsScope('research')
|
||||
expect($settingsRequestProfile.get()).toBe('research')
|
||||
|
||||
setSettingsScope('default')
|
||||
expect($settingsRequestProfile.get()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops the override on an app-wide profile switch', () => {
|
||||
setSettingsScope('research')
|
||||
expect($settingsScopeOverride.get()).toBe('research')
|
||||
|
||||
@@ -16,6 +16,23 @@ export const $settingsScopeProfile = computed([$settingsScopeOverride, $activeGa
|
||||
normalizeProfileKey(override ?? active)
|
||||
)
|
||||
|
||||
// ── Request-scope form (THE value to hand to API helpers) ──────────────────
|
||||
// The store contract and the API contract disagree about `null`:
|
||||
// - here, `null` means "follow the app's active profile" (no override);
|
||||
// - in api/client.ts `profileScoped()`/`capabilityScoped()`, `null` means
|
||||
// "deliberately suppress the active profile and target primary/default" —
|
||||
// only `undefined` falls back to the active profile.
|
||||
// Passing the raw override into an API helper therefore silently retargets
|
||||
// every read/write to the primary profile whenever no override is set — the
|
||||
// "model change reverts when I re-enter the tab" class of bug (#90549: the
|
||||
// page WROTE the right profile but READ primary back). Always send this
|
||||
// computed (or `override ?? undefined`) on requests; keep the raw override
|
||||
// only for UI concerns (selector highlight, cache keys, remount keys).
|
||||
export const $settingsRequestProfile = computed(
|
||||
$settingsScopeOverride,
|
||||
(override): string | undefined => override ?? undefined
|
||||
)
|
||||
|
||||
// Select the profile the settings pages should edit. Picking the app's active
|
||||
// profile stores `null` (no override) so the scope keeps following the app on
|
||||
// profile switches — and requests keep their unscoped default shape.
|
||||
|
||||
Reference in New Issue
Block a user