From 15d6c06dbc8de72379330c43031a5660b4bc0338 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 20:51:05 +0700 Subject: [PATCH] fix(targets): snapshot active profiles during droid prune - copy activeProfiles at call time before lock acquisition - validate profile names inside locked section - add tests for invalid names and post-call array mutation --- src/targets/droid-config-manager.ts | 12 ++++-- .../unit/targets/droid-config-manager.test.ts | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index 348e345d..3483a2d3 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -386,8 +386,8 @@ export async function listCcsModels(): Promise> { * Removes ccs-* entries whose profile no longer exists in active profiles. */ export async function pruneOrphanedModels(activeProfiles: string[]): Promise { - // Validate all profile names before pruning - activeProfiles.forEach((profile) => validateProfileName(profile)); + // Snapshot at call time so caller-side mutation cannot affect filtering while lock is pending. + const activeProfilesSnapshot = [...activeProfiles]; ensureFactoryDir(); const settingsPath = getSettingsPath(); @@ -397,6 +397,12 @@ export async function pruneOrphanedModels(activeProfiles: string[]): Promise(); + for (const profile of activeProfilesSnapshot) { + validateProfileName(profile); + activeProfileSet.add(profile); + } + if (!fs.existsSync(settingsPath)) return 0; const settings = readDroidSettings(); @@ -406,7 +412,7 @@ export async function pruneOrphanedModels(activeProfiles: string[]): Promise { const profile = parseManagedProfile(m.displayName); if (profile) { - return activeProfiles.includes(profile); + return activeProfileSet.has(profile); } // Drop malformed managed entries; keep user-managed entries. diff --git a/tests/unit/targets/droid-config-manager.test.ts b/tests/unit/targets/droid-config-manager.test.ts index 3df8cb5d..dd1c4675 100644 --- a/tests/unit/targets/droid-config-manager.test.ts +++ b/tests/unit/targets/droid-config-manager.test.ts @@ -570,6 +570,48 @@ describe('droid-config-manager', () => { expect(settings.customModels).toHaveLength(1); expect(settings.customModels[0].displayName).toBe('My GPT'); }); + + it('should reject invalid active profile names', async () => { + await expect(pruneOrphanedModels(['bad profile'])).rejects.toThrow(/Invalid profile name/); + }); + + it('should use active profile snapshot taken at call time', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: [ + { + model: 'opus', + displayName: 'CCS gemini', + baseUrl: 'x', + apiKey: 'y', + provider: 'anthropic', + }, + { + model: 'sonnet', + displayName: 'CCS codex', + baseUrl: 'x', + apiKey: 'y', + provider: 'anthropic', + }, + ], + }) + ); + + const activeProfiles = ['gemini']; + const prunePromise = pruneOrphanedModels(activeProfiles); + activeProfiles.push('codex'); // Mutation after call should not affect in-flight prune decision. + + const removed = await prunePromise; + expect(removed).toBe(1); + + const models = await listCcsModels(); + expect(models.size).toBe(1); + expect(models.has('gemini')).toBe(true); + expect(models.has('codex')).toBe(false); + }); }); describe('concurrent writes', () => {