Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
eb5e65b
fix(core): purge deleted provider profiles from ProviderSettingsManager
Sep 20, 2026
3a0747d
test(core): cover fail-fast ordering when profile config purge rejects
myk1yt Sep 27, 2026
4b54f4f
fix(core): activate replacement via standard path when deleting activ…
myk1yt Sep 27, 2026
4a370fd
chore: retrigger automated review after thread response
myk1yt Oct 30, 2026
9cc37bd
fix(core): make active-profile deletion recoverable
myk1yt Oct 30, 2026
c30971c
test(core): cover task rewiring when the active profile is deleted
myk1yt Oct 30, 2026
4315fdc
fix(core): serialize active-profile deletion and bound its rollback
myk1yt Oct 30, 2026
fc5c21e
style(core): apply prettier formatting to profile deletion changes
myk1yt Oct 30, 2026
70ba377
fix(core): read deletion state inside the queue and prefer configured…
myk1yt Oct 30, 2026
f04b356
Merge remote-tracking branch 'upstream/main' into fix/delete-profile-…
myk1yt Oct 30, 2026
257eb9c
fix(core): purge deleted profile secrets and serialize the deletion r…
myk1yt Oct 30, 2026
69a7d7d
fix(core): drop orphaned mode bindings when a provider profile is del…
myk1yt Oct 30, 2026
3f0e581
fix(core): bound the profile-mutation queue and make the delete rollb…
myk1yt Oct 30, 2026
1b2b008
fix(core): keep a provider's own secrets when a partial profile write…
myk1yt Oct 30, 2026
99a95bc
fix(core): purge deleted profile secrets explicitly and route the web…
myk1yt Oct 30, 2026
0b6b934
fix(core): derive the delete purge keep-set from the surviving provid…
myk1yt Oct 30, 2026
0571008
fix(core): make the timed-out purge compensation atomic and store-der…
myk1yt Oct 30, 2026
f779686
fix(core): gate the failed-restore reconcile's fallback activation
myk1yt Oct 30, 2026
7c3feed
fix(core): post the rollback state, widen the keep-set, surface a fai…
myk1yt Oct 30, 2026
311e784
test(core): back-port the profile-purge spec harness and the T1/T4/T5…
myk1yt Oct 30, 2026
773ef7c
refactor(core): trim the profile-purge delta to the mutation budget
myk1yt Oct 30, 2026
20d0812
fix(core): close the profile-purge reconcile races CodeRabbit flagged
myk1yt Oct 30, 2026
c23dde1
style(core): satisfy prettier on the fix-8 delta
myk1yt Oct 30, 2026
e9046d8
fix(core): complete the profile-purge compensation path
Oct 31, 2026
dc6e0dc
Merge commit '311e784ae' into fix/profile-purge-reconcile-ttl
Oct 31, 2026
a7301f3
fix(core): keep the active profile when a delete fails before its purge
myk1yt Oct 31, 2026
3b2f073
fix(core): compare the whole profile entry in the undo mirror gate
myk1yt Oct 31, 2026
afb629e
fix(core): derive a profile's secret keys from its provider as well a…
myk1yt Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions packages/types/src/__tests__/provider-settings.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ANTHROPIC_API_PROTOCOL, OPENAI_API_PROTOCOL, providerIdentifiers, providerNames } from "../index.js"
import { codebaseIndexProviderSchema } from "../codebase-index.js"
import { isSecretStateKey } from "../global-settings.js"
import {
getApiProtocol,
OPEN_AI_CODEX_SERVICE_TIER_KEY,
Expand All @@ -21,6 +23,56 @@ describe("provider settings discriminated union", () => {
it.each(providerNames)("accepts the %s provider branch", (apiProvider) => {
expect(providerSettingsSchemaDiscriminated.safeParse({ apiProvider }).success).toBe(true)
})

it("assigns every provider-owned secret key to exactly one provider", () => {
// Secret-key ownership is derived from this union and decides which credentials survive a
// profile write, so a key declared by two branches would make that decision ambiguous: a
// write naming either provider would preserve the other's secret. The invariant is one
// owner per key, and it has to hold for the whole union, not just today's branches.
const owners = new Map<string, string[]>()

for (const branch of providerSettingsSchemaDiscriminated.options) {
const literal = branch.shape.apiProvider

// The union's last branch is the `apiProvider: undefined` default, which owns nothing.
if (!("value" in literal) || typeof literal.value !== "string") continue

for (const key of Object.keys(branch.shape)) {
if (!isSecretStateKey(key)) continue

owners.set(key, [...(owners.get(key) ?? []), literal.value])
}
}

const shared = [...owners].filter(([, providers]) => providers.length > 1)

expect(shared).toEqual([])
// Guard against the map silently going empty (e.g. a renamed `apiProvider` field), which
// would make the assertion above pass while proving nothing.
expect(owners.size).toBeGreaterThan(0)
})

it("leaves the flat codebase-index credentials owned by no provider", () => {
// The index credentials are part of `ProviderSettings` but belong to no profile: they are
// written flat and every profile's discriminated parse strips them. A provider branch that
// started declaring one would make a profile switch delete the user's index credentials.
const ownedByProvider = new Set<string>()

for (const branch of providerSettingsSchemaDiscriminated.options) {
const literal = branch.shape.apiProvider

if (!("value" in literal) || typeof literal.value !== "string") continue

for (const key of Object.keys(branch.shape)) {
ownedByProvider.add(key)
}
}

const indexSecretKeys = Object.keys(codebaseIndexProviderSchema.shape).filter((key) => isSecretStateKey(key))

expect(indexSecretKeys.length).toBeGreaterThan(0)
expect(indexSecretKeys.filter((key) => ownedByProvider.has(key))).toEqual([])
})
})

describe("OpenAI-compatible extra body settings", () => {
Expand Down
143 changes: 140 additions & 3 deletions src/core/config/ContextProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import {
type GlobalState,
type RooCodeSettings,
providerSettingsSchema,
providerSettingsSchemaDiscriminated,
globalSettingsSchema,
codebaseIndexProviderSchema,
isSecretStateKey,
isProviderName,
isRetiredProvider,
Expand All @@ -32,6 +34,83 @@ const PASS_THROUGH_STATE_KEYS = ["taskHistory"]

export const isPassThroughStateKey = (key: string) => PASS_THROUGH_STATE_KEYS.includes(key)

// Codebase-index credentials are part of `ProviderSettings` but are not owned by a
// provider profile: the index settings handler writes them flat and `saveConfig`'s
// discriminated parse strips them from every profile blob. Clearing them here would
// delete the user's index credentials on every profile switch.
const FLAT_ONLY_PROVIDER_SECRET_KEYS = new Set<string>(Object.keys(codebaseIndexProviderSchema.shape))

// The secret keys a provider profile can own: `PROVIDER_SETTINGS_KEYS ∩ SECRET_STATE_KEYS`,
// minus the FLAT_ONLY carve-out. The global image-generation key is in `SECRET_STATE_KEYS` but
// not in `ProviderSettings`, so it is excluded here exactly as the clear-out loop excludes it.
const PROVIDER_SECRET_KEYS = new Set<string>(
PROVIDER_SETTINGS_KEYS.filter((key) => isSecretStateKey(key) && !FLAT_ONLY_PROVIDER_SECRET_KEYS.has(key)),
)

// Which provider owns each secret key, read from the same discriminated union that
// `saveConfig` parses profiles with: a provider branch declares the keys its own
// profile blob carries, so ownership is exactly "this key appears in this branch".
// Every provider-owned secret key has exactly one owner (verified across all 37
// branches); the codebase-index credentials have none, which is why they are the
// FLAT_ONLY carve-out above.
const PROVIDER_SECRET_KEY_OWNERS = new Map<string, Set<string>>()

for (const branch of providerSettingsSchemaDiscriminated.options) {
const literal = branch.shape.apiProvider

// The union's last branch is the `apiProvider: undefined` default, which owns nothing.
if (!("value" in literal) || typeof literal.value !== "string") continue

for (const key of Object.keys(branch.shape)) {
if (!isSecretStateKey(key)) continue

const owners = PROVIDER_SECRET_KEY_OWNERS.get(key) ?? new Set<string>()
owners.add(literal.value)
PROVIDER_SECRET_KEY_OWNERS.set(key, owners)
}
}

/**
* Whether the provider named by a write is the one that owns `key`.
*
* A partial write can legitimately omit a secret the writing provider still uses:
* `api.getConfiguration()` strips secrets (`src/extension/api.ts`), so a
* save-then-reactivate round trip rewrites a profile from a snapshot that has no
* secret in it. Treating that omission as "this profile uses none of these" deleted
* the live profile's credentials from SecretStorage and broke every task rehydrated
* afterwards. Ownership answers the question the omission cannot.
*
* Returns `undefined` when ownership is unknown (no provider named, or a retired
* provider, whose branch is not in the union), so callers keep the old
* presence-based semantics instead of guessing.
*/
export const providerOwnsSecretKey = (apiProvider: unknown, key: string): boolean | undefined => {
if (typeof apiProvider !== "string" || !isProviderName(apiProvider)) {
return undefined
}

return PROVIDER_SECRET_KEY_OWNERS.get(key)?.has(apiProvider) ?? false
}

/**
* The secret keys a stored profile uses.
*
* A profile blob is the record of which credentials that profile uses: `saveConfig`'s
* discriminated parse keeps only the branch's own keys, so the keys present in the blob
* are exactly the ones the profile owns. Used to purge a deleted profile's credentials
* explicitly instead of inferring them from a later write.
*
* The blob alone is not enough: `api.getConfiguration()` strips secret fields, so a profile
* saved from that round trip declares none while its live credential sits in SecretStorage.
* The profile's own `apiProvider` still names the keys it owns, so the candidates are the
* union of both. A provider with no ownership branch keeps the presence-based answer.
*
* Membership is `PROVIDER_SETTINGS_KEYS ∩ SECRET_STATE_KEYS`, the same set the clear-out
* loop iterates: it excludes the global image-generation key, which no profile blob owns.
*/
export const getProfileSecretKeys = (config: Record<string, unknown>): string[] =>
[...PROVIDER_SECRET_KEYS].filter((key) => key in config || providerOwnsSecretKey(config.apiProvider, key) === true)

const globalSettingsExportSchema = globalSettingsSchema.omit({
taskHistory: true,
listApiConfigMeta: true,
Expand Down Expand Up @@ -507,7 +586,18 @@ export class ContextProxy {
return sanitizedValues
}

public async setProviderSettings(values: ProviderSettings) {
/**
* Write a provider slice, clearing out the settings it no longer carries.
*
* `expectedProvider` is the provider the caller resolved from the manager for the profile
* being written. It is the ownership discriminator, and it must not be read off `values`:
* `values.apiProvider` is whatever the caller put in the slice, so a crafted partial write
* could name the owner of a secret it wants preserved and keep a credential that belongs to
* a different profile. Callers that activate or persist a stored profile pass the provider
* from the manager blob; callers with no manager record (import, tests) omit it and fall
* back to the slice's own field.
*/
public async setProviderSettings(values: ProviderSettings, expectedProvider?: string) {
// Explicitly clear out any old API configuration values before that
// might not be present in the new configuration.
// If a value is not present in the new configuration, then it is assumed
Expand All @@ -523,14 +613,61 @@ export class ContextProxy {
}
}

// Secret keys live in `secretCache`, not `stateCache`, so they need their own
// presence check. Without this a deleted profile's differently-named secrets
// (`openRouterApiKey` when the replacement uses `apiKey`) survive the switch and
// keep being merged back by `getValues`.
//
// A secret the writing provider still owns is not stale, even when the write omits
// it: partial writes are normal (`api.getConfiguration()` strips secrets, so a
// save-then-reactivate round trip rewrites a profile from a secret-less snapshot).
// Purging on omission alone deleted the live profile's credentials, and every task
// rehydrated afterwards ran without an API key.
const owner = expectedProvider ?? values.apiProvider

const isStale = (key: string) => {
if (!isSecretStateKey(key)) {
return !!this.stateCache[key as GlobalStateKey]
}

if (!this.secretCache[key as SecretStateKey]) {
return false
}

return providerOwnsSecretKey(owner, key) !== true
}

await this.setValues({
...PROVIDER_SETTINGS_KEYS.filter((key) => !isSecretStateKey(key))
.filter((key) => !!this.stateCache[key])
...PROVIDER_SETTINGS_KEYS.filter((key) => !FLAT_ONLY_PROVIDER_SECRET_KEYS.has(key))
.filter(isStale)
.reduce((acc, key) => ({ ...acc, [key]: undefined }), {} as ProviderSettings),
...values,
})
}

/**
* Delete the named secrets from the cache and from SecretStorage.
*
* This is the explicit form of the clear-out above: the caller names the keys, so the
* decision does not depend on what a later write happens to omit. A profile delete uses it
* to remove exactly the credentials the deleted profile's own blob declared, which is the
* only way to cover the inactive branch (no replacement write runs) and a same-provider
* succession (ownership would preserve the deleted profile's key by design).
*
* Keys outside `PROVIDER_SETTINGS_KEYS ∩ SECRET_STATE_KEYS` are ignored, as are the flat
* codebase-index credentials: the global image-generation secret and the index keys belong
* to no profile, so a profile delete must not take them with it.
*/
public async purgeProviderSecrets(keys: Iterable<string>): Promise<void> {
const purgeable = [...keys].filter((key) => PROVIDER_SECRET_KEYS.has(key))

if (purgeable.length === 0) {
return
}

await this.setValues(purgeable.reduce((acc, key) => ({ ...acc, [key]: undefined }), {} as RooCodeSettings))
}

/**
* RooCodeSettings
*/
Expand Down
85 changes: 82 additions & 3 deletions src/core/config/ProviderSettingsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,34 @@ export class ProviderSettingsManager {
}
}

/**
* Save a config only when no config with that name exists, as one locked step.
*
* A caller that has to restore a profile it is not sure still belongs there (the undo of a
* purge that committed after the caller timed out) cannot check-then-write: a save for the
* same name can land between the check and the write, and the restore would then overwrite
* it with a stale snapshot. The existence check and the write share one lock here, so a
* concurrent `saveConfig` either lands before the check (and this call skips) or after the
* write (and wins). Returns the written id, or `undefined` when the name was taken.
*
* The blob is stored verbatim: the caller restores a config this manager produced, so
* re-parsing it would only strip fields (a retired provider's legacy keys) it already holds.
*/
public async saveConfigIfAbsent(name: string, config: ProviderSettingsWithId): Promise<string | undefined> {
return await this.lock(async () => {
const providerProfiles = await this.load()

if (providerProfiles.apiConfigs[name]) {
return undefined
}

const id = config.id || this.generateId()
providerProfiles.apiConfigs[name] = { ...config, id }
await this.store(providerProfiles)
return id
})
}

public async getProfile(
params: { name: string } | { id: string },
): Promise<ProviderSettingsWithId & { name: string }> {
Expand Down Expand Up @@ -503,9 +531,12 @@ export class ProviderSettingsManager {
}

/**
* Delete a config by name.
* Delete a config by name. The per-mode bindings that point at the deleted profile are dropped
* with it: a surviving binding makes the next switch to that mode resolve no profile and
* silently keep the current one. The dropped bindings are returned so a caller that rolls the
* deletion back can re-apply them.
*/
public async deleteConfig(name: string) {
public async deleteConfig(name: string): Promise<{ removedModeConfigs: Record<string, string> }> {
try {
return await this.lock(async () => {
const providerProfiles = await this.load()
Expand All @@ -518,8 +549,26 @@ export class ProviderSettingsManager {
throw new Error(`Cannot delete the last remaining configuration`)
}

const deletedId = providerProfiles.apiConfigs[name].id
delete providerProfiles.apiConfigs[name]

const removedModeConfigs: Record<string, string> = {}
// A rename saves the profile under its new name with the same `id` before deleting
// the old one, so only an id no remaining profile carries is an orphan.
const idStillInUse =
!!deletedId && Object.values(providerProfiles.apiConfigs).some((config) => config.id === deletedId)

if (deletedId && !idStillInUse) {
for (const [mode, configId] of Object.entries(providerProfiles.modeApiConfigs ?? {})) {
if (configId === deletedId) {
removedModeConfigs[mode] = configId
delete providerProfiles.modeApiConfigs![mode]
}
}
}

await this.store(providerProfiles)
return { removedModeConfigs }
})
} catch (error) {
throw new Error(`Failed to delete config: ${error}`)
Expand All @@ -542,18 +591,33 @@ export class ProviderSettingsManager {

/**
* Set the API config for a specific mode.
*
* `onlyIfUnbound` makes the write conditional, for the rollback that re-applies the bindings a
* purge dropped. That rollback must not overwrite a mode a newer selection has already bound
* (its queue advances at its timeout, so a newer binding can commit while the aborted rollback
* still runs), and it must not re-apply a binding whose profile no longer exists (the restore's
* `saveConfig` can reject, and a dangling id makes the next switch to that mode resolve nothing
* and silently keep the current profile). Both checks and the write share this one lock, so
* neither can straddle a concurrent purge or rename. Returns whether the write happened.
*/
public async setModeConfig(mode: Mode, configId: string) {
public async setModeConfig(mode: Mode, configId: string, { onlyIfUnbound = false } = {}): Promise<boolean> {
try {
return await this.lock(async () => {
const providerProfiles = await this.load()

if (onlyIfUnbound) {
const exists = Object.values(providerProfiles.apiConfigs).some((config) => config.id === configId)
if (!exists || providerProfiles.modeApiConfigs?.[mode] !== undefined) return false
}

// Ensure the per-mode config map exists
if (!providerProfiles.modeApiConfigs) {
providerProfiles.modeApiConfigs = {}
}
// Assign the chosen config ID to this mode
providerProfiles.modeApiConfigs[mode] = configId
await this.store(providerProfiles)
return true
})
} catch (error) {
throw new Error(`Failed to set mode config: ${error}`)
Expand All @@ -574,6 +638,21 @@ export class ProviderSettingsManager {
}
}

/**
* Get every per-mode config binding. Callers that mirror this map into `ContextProxy` read it
* here rather than recomputing it, so the two stores cannot drift.
*/
public async getModeApiConfigs(): Promise<Record<string, string>> {
try {
return await this.lock(async () => {
const { modeApiConfigs } = await this.load()
return { ...modeApiConfigs }
})
} catch (error) {
throw new Error(`Failed to get mode configs: ${error}`)
}
}

public async export() {
try {
return await this.lock(async () => {
Expand Down
Loading
Loading