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
This commit is contained in:
Tam Nhu Tran
2026-02-17 20:53:03 +07:00
parent 812bb5c0a5
commit 15d6c06dbc
2 changed files with 51 additions and 3 deletions
+9 -3
View File
@@ -386,8 +386,8 @@ export async function listCcsModels(): Promise<Map<string, DroidCustomModel>> {
* Removes ccs-* entries whose profile no longer exists in active profiles.
*/
export async function pruneOrphanedModels(activeProfiles: string[]): Promise<number> {
// 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<num
try {
release = await acquireFactoryLock(3);
const activeProfileSet = new Set<string>();
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<num
settings.customModels = settings.customModels.filter((m) => {
const profile = parseManagedProfile(m.displayName);
if (profile) {
return activeProfiles.includes(profile);
return activeProfileSet.has(profile);
}
// Drop malformed managed entries; keep user-managed entries.
@@ -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', () => {