feature: handle removed extensions (#8797)

This commit is contained in:
Lifei Zhou
2026-04-27 13:00:56 +10:00
committed by GitHub
parent 93eb122687
commit 8790b17c38
6 changed files with 165 additions and 193 deletions
+18 -11
View File
@@ -9,7 +9,7 @@ import {
removeExtension as apiRemoveExtension, removeExtension as apiRemoveExtension,
providers, providers,
} from '../api'; } from '../api';
import { syncBundledExtensions } from './settings/extensions'; import { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './settings/extensions';
import type { import type {
ConfigResponse, ConfigResponse,
UpsertConfigQuery, UpsertConfigQuery,
@@ -88,16 +88,19 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
[reloadConfig] [reloadConfig]
); );
const read = useCallback(async (key: string, is_secret: boolean = false, options?: { throwOnError?: boolean }) => { const read = useCallback(
const query: ConfigKeyQuery = { key: key, is_secret: is_secret }; async (key: string, is_secret: boolean = false, options?: { throwOnError?: boolean }) => {
const response = await readConfig({ const query: ConfigKeyQuery = { key: key, is_secret: is_secret };
body: query, const response = await readConfig({
}); body: query,
if (options?.throwOnError && response.error) { });
throw response.error; if (options?.throwOnError && response.error) {
} throw response.error;
return response.data; }
}, []); return response.data;
},
[]
);
const remove = useCallback( const remove = useCallback(
async (key: string, is_secret: boolean) => { async (key: string, is_secret: boolean) => {
@@ -226,6 +229,10 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
const query: ExtensionQuery = { name, config, enabled }; const query: ExtensionQuery = { name, config, enabled };
await apiAddExtension({ body: query }); await apiAddExtension({ body: query });
}; };
const removeExtensionForSync = async (name: string) => {
await apiRemoveExtension({ path: { name } });
};
extensions = await pruneDeprecatedBundledExtensions(extensions, removeExtensionForSync);
await syncBundledExtensions(extensions, addExtensionForSync); await syncBundledExtensions(extensions, addExtensionForSync);
// Reload extensions after sync // Reload extensions after sync
const refreshedResponse = await apiGetExtensions(); const refreshedResponse = await apiGetExtensions();
@@ -7,6 +7,7 @@ import { Switch } from '../ui/switch';
import { FixedExtensionEntry, useConfig } from '../ConfigContext'; import { FixedExtensionEntry, useConfig } from '../ConfigContext';
import { toastService } from '../../toasts'; import { toastService } from '../../toasts';
import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList'; import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList';
import { nameToKey } from '../settings/extensions/utils';
import { ExtensionConfig, getSessionExtensions } from '../../api'; import { ExtensionConfig, getSessionExtensions } from '../../api';
import { addToAgent, removeFromAgent } from '../settings/extensions/agent-api'; import { addToAgent, removeFromAgent } from '../settings/extensions/agent-api';
import { import {
@@ -230,15 +231,29 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
); );
} }
const sessionExtensionNames = new Set(sessionExtensions.map((ext) => ext.name)); const sessionExtensionKeys = new Set(sessionExtensions.map((ext) => nameToKey(ext.name)));
const globalExtensionKeys = new Set(allExtensions.map((ext) => nameToKey(ext.name)));
return allExtensions.map( const mergedExtensions = allExtensions.map(
(ext) => (ext) =>
({ ({
...ext, ...ext,
enabled: sessionExtensionNames.has(ext.name), enabled: sessionExtensionKeys.has(nameToKey(ext.name)),
}) as FixedExtensionEntry }) as FixedExtensionEntry
); );
for (const sessionExtension of sessionExtensions) {
if (globalExtensionKeys.has(nameToKey(sessionExtension.name))) {
continue;
}
mergedExtensions.push({
...sessionExtension,
enabled: true,
});
}
return mergedExtensions;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [allExtensions, sessionExtensions, isHubView, hubUpdateTrigger]); }, [allExtensions, sessionExtensions, isHubView, hubUpdateTrigger]);
@@ -266,6 +281,9 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
return extensionsList.filter((ext) => ext.enabled).length; return extensionsList.filter((ext) => ext.enabled).length;
}, [extensionsList]); }, [extensionsList]);
const shouldHideTrigger =
extensionsList.length === 0 || (!isHubView && !isSessionExtensionsLoaded);
return ( return (
<DropdownMenu <DropdownMenu
open={isOpen} open={isOpen}
@@ -284,7 +302,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
> >
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
className={`flex items-center [&_svg]:size-4 text-text-primary/70 hover:text-text-primary hover:scale-100 hover:bg-transparent text-xs cursor-pointer ${allExtensions.length === 0 || (!isHubView && !isSessionExtensionsLoaded) ? 'invisible' : ''}`} className={`flex items-center [&_svg]:size-4 text-text-primary/70 hover:text-text-primary hover:scale-100 hover:bg-transparent text-xs cursor-pointer ${shouldHideTrigger ? 'invisible' : ''}`}
title={intl.formatMessage(i18n.manageExtensions)} title={intl.formatMessage(i18n.manageExtensions)}
> >
<Puzzle className="mr-1 h-4 w-4" /> <Puzzle className="mr-1 h-4 w-4" />
@@ -309,7 +327,9 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
autoFocus autoFocus
/> />
<p className="text-xs text-text-primary/60 mt-1.5"> <p className="text-xs text-text-primary/60 mt-1.5">
{intl.formatMessage(isHubView ? i18n.extensionsForNewChats : i18n.extensionsForThisSession)} {intl.formatMessage(
isHubView ? i18n.extensionsForNewChats : i18n.extensionsForThisSession
)}
</p> </p>
</div> </div>
<div <div
@@ -319,7 +339,9 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
> >
{sortedExtensions.length === 0 ? ( {sortedExtensions.length === 0 ? (
<div className="px-2 py-4 text-center text-sm text-text-primary/70"> <div className="px-2 py-4 text-center text-sm text-text-primary/70">
{intl.formatMessage(searchQuery ? i18n.noExtensionsFound : i18n.noExtensionsAvailable)} {intl.formatMessage(
searchQuery ? i18n.noExtensionsFound : i18n.noExtensionsAvailable
)}
</div> </div>
) : ( ) : (
sortedExtensions.map((ext) => { sortedExtensions.map((ext) => {
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { isDeprecatedGoogleDriveExtension, syncBundledExtensions } from './bundled-extensions'; import { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './bundled-extensions';
import type { FixedExtensionEntry } from '../../ConfigContext'; import type { FixedExtensionEntry } from '../../ConfigContext';
vi.mock('./bundled-extensions.json', () => ({ vi.mock('./bundled-extensions.json', () => ({
@@ -28,143 +28,11 @@ vi.mock('./bundled-extensions.json', () => ({
], ],
})); }));
describe('isDeprecatedGoogleDriveExtension', () => { vi.mock('./deprecated-bundled-extensions.json', () => ({
it('returns true for builtin googledrive', () => { default: [{ id: 'googledrive' }, { id: 'old-bundled-extension' }],
const ext = { }));
name: 'Google Drive',
type: 'builtin',
description: 'Google Drive extension',
enabled: true,
bundled: true,
} as FixedExtensionEntry;
expect(isDeprecatedGoogleDriveExtension(ext)).toBe(true);
});
it('returns true for builtin google_drive', () => {
const ext = {
name: 'google_drive',
type: 'builtin',
description: 'Google Drive extension',
enabled: true,
bundled: true,
} as FixedExtensionEntry;
expect(isDeprecatedGoogleDriveExtension(ext)).toBe(true);
});
it('returns true for stdio googledrive with GOOGLE_DRIVE_CREDENTIALS_PATH', () => {
const ext = {
name: 'Google Drive',
type: 'stdio',
description: 'Google Drive extension',
cmd: 'some-cmd',
args: [],
env_keys: ['GOOGLE_DRIVE_CREDENTIALS_PATH'],
enabled: true,
bundled: true,
} as FixedExtensionEntry;
expect(isDeprecatedGoogleDriveExtension(ext)).toBe(true);
});
it('returns true for stdio googledrive with GOOGLE_DRIVE_OAUTH_PATH', () => {
const ext = {
name: 'Google Drive',
type: 'stdio',
description: 'Google Drive extension',
cmd: 'some-cmd',
args: [],
env_keys: ['GOOGLE_DRIVE_OAUTH_PATH'],
enabled: true,
bundled: true,
} as FixedExtensionEntry;
expect(isDeprecatedGoogleDriveExtension(ext)).toBe(true);
});
it('returns false for stdio googledrive without deprecated env keys', () => {
const ext = {
name: 'Google Drive',
type: 'stdio',
description: 'Google Drive extension',
cmd: 'some-cmd',
args: [],
env_keys: [],
enabled: true,
bundled: true,
} as FixedExtensionEntry;
expect(isDeprecatedGoogleDriveExtension(ext)).toBe(false);
});
it('returns false for non-googledrive extensions', () => {
const ext = {
name: 'developer',
type: 'builtin',
description: 'Developer tools',
enabled: true,
bundled: true,
} as FixedExtensionEntry;
expect(isDeprecatedGoogleDriveExtension(ext)).toBe(false);
});
it('returns false for non-googledrive stdio with those env keys', () => {
const ext = {
name: 'some-other-ext',
type: 'stdio',
description: 'Other extension',
cmd: 'some-cmd',
args: [],
env_keys: ['GOOGLE_DRIVE_CREDENTIALS_PATH'],
enabled: true,
bundled: true,
} as FixedExtensionEntry;
expect(isDeprecatedGoogleDriveExtension(ext)).toBe(false);
});
});
describe('syncBundledExtensions', () => { describe('syncBundledExtensions', () => {
it('overwrites deprecated builtin googledrive extension', async () => {
const addExtensionFn = vi.fn().mockResolvedValue(undefined);
const existingExtensions = [
{
name: 'googledrive',
type: 'builtin',
description: 'Google Drive',
enabled: true,
bundled: true,
},
] as FixedExtensionEntry[];
await syncBundledExtensions(existingExtensions, addExtensionFn);
expect(addExtensionFn).toHaveBeenCalledWith(
'googledrive',
expect.objectContaining({ type: 'stdio', bundled: true }),
true
);
});
it('overwrites stdio googledrive with deprecated env keys', async () => {
const addExtensionFn = vi.fn().mockResolvedValue(undefined);
const existingExtensions = [
{
name: 'googledrive',
type: 'stdio',
description: 'Google Drive',
cmd: 'some-cmd',
args: [],
env_keys: ['GOOGLE_DRIVE_CREDENTIALS_PATH'],
enabled: true,
bundled: true,
},
] as FixedExtensionEntry[];
await syncBundledExtensions(existingExtensions, addExtensionFn);
expect(addExtensionFn).toHaveBeenCalledWith(
'googledrive',
expect.objectContaining({ type: 'stdio', bundled: true, env_keys: [] }),
true
);
});
it('skips already bundled non-deprecated extensions', async () => { it('skips already bundled non-deprecated extensions', async () => {
const addExtensionFn = vi.fn().mockResolvedValue(undefined); const addExtensionFn = vi.fn().mockResolvedValue(undefined);
const existingExtensions = [ const existingExtensions = [
@@ -187,3 +55,82 @@ describe('syncBundledExtensions', () => {
); );
}); });
}); });
describe('pruneDeprecatedBundledExtensions', () => {
it('removes deprecated bundled extensions', async () => {
const removeExtensionFn = vi.fn().mockResolvedValue(undefined);
const existingExtensions = [
{
name: 'old-bundled-extension',
type: 'builtin',
description: 'Old bundled extension',
enabled: true,
bundled: true,
},
] as FixedExtensionEntry[];
const remainingExtensions = await pruneDeprecatedBundledExtensions(
existingExtensions,
removeExtensionFn
);
expect(removeExtensionFn).toHaveBeenCalledWith('old-bundled-extension');
expect(remainingExtensions).toEqual([]);
});
it('does not remove non-bundled deprecated extensions', async () => {
const removeExtensionFn = vi.fn().mockResolvedValue(undefined);
const existingExtensions = [
{
name: 'old-bundled-extension',
type: 'builtin',
description: 'Old bundled extension',
enabled: true,
bundled: false,
},
] as FixedExtensionEntry[];
const remainingExtensions = await pruneDeprecatedBundledExtensions(
existingExtensions,
removeExtensionFn
);
expect(removeExtensionFn).not.toHaveBeenCalled();
expect(remainingExtensions).toEqual(existingExtensions);
});
it('allows same-id bundled extensions to be re-added after prune', async () => {
const removeExtensionFn = vi.fn().mockResolvedValue(undefined);
const addExtensionFn = vi.fn().mockResolvedValue(undefined);
const existingExtensions = [
{
name: 'Google Drive',
type: 'stdio',
description: 'Google Drive extension',
cmd: 'some-cmd',
args: [],
env_keys: [],
enabled: true,
bundled: true,
},
] as FixedExtensionEntry[];
const remainingExtensions = await pruneDeprecatedBundledExtensions(
existingExtensions,
removeExtensionFn
);
await syncBundledExtensions(remainingExtensions, addExtensionFn);
expect(removeExtensionFn).toHaveBeenCalledWith('googledrive');
expect(addExtensionFn).toHaveBeenCalledWith(
'googledrive',
expect.objectContaining({
type: 'stdio',
name: 'googledrive',
bundled: true,
}),
true
);
});
});
@@ -1,6 +1,7 @@
import type { ExtensionConfig } from '../../../api/types.gen'; import type { ExtensionConfig } from '../../../api/types.gen';
import { FixedExtensionEntry } from '../../ConfigContext'; import { FixedExtensionEntry } from '../../ConfigContext';
import bundledExtensionsData from './bundled-extensions.json'; import bundledExtensionsData from './bundled-extensions.json';
import deprecatedBundledExtensionsData from './deprecated-bundled-extensions.json';
import { nameToKey } from './utils'; import { nameToKey } from './utils';
// Type definition for built-in extensions from JSON // Type definition for built-in extensions from JSON
@@ -20,28 +21,40 @@ type BundledExtension = {
allow_configure?: boolean; allow_configure?: boolean;
}; };
const DEPRECATED_GOOGLE_DRIVE_IDS = ['googledrive', 'google_drive']; type DeprecatedBundledExtension = {
const DEPRECATED_GOOGLE_DRIVE_ENV_KEYS = [ id: string;
'GOOGLE_DRIVE_CREDENTIALS_PATH', };
'GOOGLE_DRIVE_OAUTH_PATH',
];
export function isDeprecatedGoogleDriveExtension(ext: FixedExtensionEntry): boolean { export function getDeprecatedBundledExtensions(): DeprecatedBundledExtension[] {
if (!DEPRECATED_GOOGLE_DRIVE_IDS.includes(nameToKey(ext.name))) { return deprecatedBundledExtensionsData as DeprecatedBundledExtension[];
return false; }
function isBundledExtension(extension: FixedExtensionEntry): boolean {
return 'bundled' in extension && extension.bundled === true;
}
export async function pruneDeprecatedBundledExtensions(
existingExtensions: FixedExtensionEntry[],
removeExtensionFn: (id: string) => Promise<void>
): Promise<FixedExtensionEntry[]> {
const deprecatedExtensionIds = new Set(getDeprecatedBundledExtensions().map((ext) => ext.id));
const remainingExtensions: FixedExtensionEntry[] = [];
for (const existingExt of existingExtensions) {
if (!isBundledExtension(existingExt)) {
remainingExtensions.push(existingExt);
continue;
}
if (!deprecatedExtensionIds.has(nameToKey(existingExt.name))) {
remainingExtensions.push(existingExt);
continue;
}
await removeExtensionFn(nameToKey(existingExt.name));
} }
if (ext.type === 'builtin') {
return true; return remainingExtensions;
}
if (
ext.type === 'stdio' &&
'env_keys' in ext &&
Array.isArray(ext.env_keys) &&
ext.env_keys.some((key: string) => DEPRECATED_GOOGLE_DRIVE_ENV_KEYS.includes(key))
) {
return true;
}
return false;
} }
/** /**
@@ -66,14 +79,7 @@ export async function syncBundledExtensions(
// Find if this extension already exists // Find if this extension already exists
const existingExt = existingExtensions.find((ext) => nameToKey(ext.name) === bundledExt.id); const existingExt = existingExtensions.find((ext) => nameToKey(ext.name) === bundledExt.id);
// Skip if extension exists and is already marked as bundled, except when if (existingExt && isBundledExtension(existingExt)) {
// we must migrate deprecated extensions.
if (
existingExt &&
'bundled' in existingExt &&
existingExt.bundled &&
!isDeprecatedGoogleDriveExtension(existingExt)
) {
continue; continue;
} }
@@ -123,14 +129,3 @@ export async function syncBundledExtensions(
throw error; throw error;
} }
} }
/**
* Function to initialize all built-in extensions for a first-time user.
* This can be called when the application is first installed.
*/
export async function initializeBundledExtensions(
addExtensionFn: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>
): Promise<void> {
// Call with an empty list to ensure all built-ins are added
await syncBundledExtensions([], addExtensionFn);
}
@@ -6,7 +6,7 @@ export {
deleteExtension, deleteExtension,
} from './extension-manager'; } from './extension-manager';
export { syncBundledExtensions, initializeBundledExtensions } from './bundled-extensions'; export { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './bundled-extensions';
export { addExtensionFromDeepLink } from './deeplink'; export { addExtensionFromDeepLink } from './deeplink';