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,
providers,
} from '../api';
import { syncBundledExtensions } from './settings/extensions';
import { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './settings/extensions';
import type {
ConfigResponse,
UpsertConfigQuery,
@@ -88,16 +88,19 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
[reloadConfig]
);
const read = useCallback(async (key: string, is_secret: boolean = false, options?: { throwOnError?: boolean }) => {
const query: ConfigKeyQuery = { key: key, is_secret: is_secret };
const response = await readConfig({
body: query,
});
if (options?.throwOnError && response.error) {
throw response.error;
}
return response.data;
}, []);
const read = useCallback(
async (key: string, is_secret: boolean = false, options?: { throwOnError?: boolean }) => {
const query: ConfigKeyQuery = { key: key, is_secret: is_secret };
const response = await readConfig({
body: query,
});
if (options?.throwOnError && response.error) {
throw response.error;
}
return response.data;
},
[]
);
const remove = useCallback(
async (key: string, is_secret: boolean) => {
@@ -226,6 +229,10 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
const query: ExtensionQuery = { name, config, enabled };
await apiAddExtension({ body: query });
};
const removeExtensionForSync = async (name: string) => {
await apiRemoveExtension({ path: { name } });
};
extensions = await pruneDeprecatedBundledExtensions(extensions, removeExtensionForSync);
await syncBundledExtensions(extensions, addExtensionForSync);
// Reload extensions after sync
const refreshedResponse = await apiGetExtensions();
@@ -7,6 +7,7 @@ import { Switch } from '../ui/switch';
import { FixedExtensionEntry, useConfig } from '../ConfigContext';
import { toastService } from '../../toasts';
import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList';
import { nameToKey } from '../settings/extensions/utils';
import { ExtensionConfig, getSessionExtensions } from '../../api';
import { addToAgent, removeFromAgent } from '../settings/extensions/agent-api';
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,
enabled: sessionExtensionNames.has(ext.name),
enabled: sessionExtensionKeys.has(nameToKey(ext.name)),
}) 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
}, [allExtensions, sessionExtensions, isHubView, hubUpdateTrigger]);
@@ -266,6 +281,9 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
return extensionsList.filter((ext) => ext.enabled).length;
}, [extensionsList]);
const shouldHideTrigger =
extensionsList.length === 0 || (!isHubView && !isSessionExtensionsLoaded);
return (
<DropdownMenu
open={isOpen}
@@ -284,7 +302,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
>
<DropdownMenuTrigger asChild>
<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)}
>
<Puzzle className="mr-1 h-4 w-4" />
@@ -309,7 +327,9 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
autoFocus
/>
<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>
</div>
<div
@@ -319,7 +339,9 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
>
{sortedExtensions.length === 0 ? (
<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>
) : (
sortedExtensions.map((ext) => {
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { isDeprecatedGoogleDriveExtension, syncBundledExtensions } from './bundled-extensions';
import { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './bundled-extensions';
import type { FixedExtensionEntry } from '../../ConfigContext';
vi.mock('./bundled-extensions.json', () => ({
@@ -28,143 +28,11 @@ vi.mock('./bundled-extensions.json', () => ({
],
}));
describe('isDeprecatedGoogleDriveExtension', () => {
it('returns true for builtin googledrive', () => {
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);
});
});
vi.mock('./deprecated-bundled-extensions.json', () => ({
default: [{ id: 'googledrive' }, { id: 'old-bundled-extension' }],
}));
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 () => {
const addExtensionFn = vi.fn().mockResolvedValue(undefined);
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 { FixedExtensionEntry } from '../../ConfigContext';
import bundledExtensionsData from './bundled-extensions.json';
import deprecatedBundledExtensionsData from './deprecated-bundled-extensions.json';
import { nameToKey } from './utils';
// Type definition for built-in extensions from JSON
@@ -20,28 +21,40 @@ type BundledExtension = {
allow_configure?: boolean;
};
const DEPRECATED_GOOGLE_DRIVE_IDS = ['googledrive', 'google_drive'];
const DEPRECATED_GOOGLE_DRIVE_ENV_KEYS = [
'GOOGLE_DRIVE_CREDENTIALS_PATH',
'GOOGLE_DRIVE_OAUTH_PATH',
];
type DeprecatedBundledExtension = {
id: string;
};
export function isDeprecatedGoogleDriveExtension(ext: FixedExtensionEntry): boolean {
if (!DEPRECATED_GOOGLE_DRIVE_IDS.includes(nameToKey(ext.name))) {
return false;
export function getDeprecatedBundledExtensions(): DeprecatedBundledExtension[] {
return deprecatedBundledExtensionsData as DeprecatedBundledExtension[];
}
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;
}
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;
return remainingExtensions;
}
/**
@@ -66,14 +79,7 @@ export async function syncBundledExtensions(
// Find if this extension already exists
const existingExt = existingExtensions.find((ext) => nameToKey(ext.name) === bundledExt.id);
// Skip if extension exists and is already marked as bundled, except when
// we must migrate deprecated extensions.
if (
existingExt &&
'bundled' in existingExt &&
existingExt.bundled &&
!isDeprecatedGoogleDriveExtension(existingExt)
) {
if (existingExt && isBundledExtension(existingExt)) {
continue;
}
@@ -123,14 +129,3 @@ export async function syncBundledExtensions(
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,
} from './extension-manager';
export { syncBundledExtensions, initializeBundledExtensions } from './bundled-extensions';
export { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './bundled-extensions';
export { addExtensionFromDeepLink } from './deeplink';