[Fix] Direct local installation (#9614)

* feat: local plugin installation with Zip Slip prevention and size limits

* feat: allow nullable marketplaceId in completeInstallation for local plugin installations

* feat: update IActivatePluginCommandParams to allow nullable marketplaceId and enhance local activation logic

* feat: local plugin installation handling and state management

* fix: local plugin installation handling with unique identifiers and improved error checks (AI fixes)

* docs: remove obsolete issue references from comments

These references were tied to internal tracking issues that are now resolved
or no longer relevant within the codebase. Removing them cleans up the
comments, making them more concise and up-to-date.

* chore(cspell): add 'Dispatchable' to dictionary

Prevent false positives for a valid term used within the project.

* fix: arbitrary file access during archive extraction ("Zip Slip")

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: arbitrary file access during archive extraction ("Zip Slip")

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: stuck loading state when a local install fails validation here (e.g., missing plugin.name), downloadFailed is dispatched without pluginId, and localInstallId is never cleared from the installation store

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix: enforce plugin name requirement for local installations in activation command

* fix: update completeInstallation to accept plugin name for local installations

* fix(settings-plugin): ensure local plugin detection is accurate

Previously, a plugin was considered local if it only lacked a marketplace ID. This was
insufficient as plugins with an installation ID (even without a marketplace ID)
may still require server interaction for activation and deactivation.
This update modifies the logic to check for the absence of both marketplaceId
and installationId to correctly identify truly local plugins that can bypass
server calls.

* fix(plugin-installation): clear local install id only on success

The `_localInstallId` tracks the loading state for locally installed
plugins. Previously, it was also cleared within the `catchError`
block. This could lead to incorrect state cleanup if a download
failed or was cancelled by `switchMap` before reaching the success tap.
By only clearing the id within the `tap` operator after successful
completion, the installation state is correctly managed.

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Kifungo A
2026-03-16 21:34:58 +01:00
committed by GitHub
co-authored by Copilot Autofix powered by AI cubic-dev-ai[bot]
parent 1c6889b6b5
commit 69087ed893
9 changed files with 478 additions and 51 deletions
+2 -1
View File
@@ -879,7 +879,8 @@
"sidemenu",
"ptdu",
"ptau",
"Centralises"
"Centralises",
"Dispatchable"
],
"useGitignore": true,
"ignorePaths": [
@@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { PluginMetadataService } from '../database/plugin-metadata.service';
import { PluginEventManager } from '../events/plugin-event.manager';
import { IPlugin, IPluginManager, IPluginMetadata, IPluginMetadataFindOne, PluginDownloadContextType } from '../shared';
import { IPlugin, IPluginManager, IPluginMetadata, IPluginMetadataFindOne, IPluginMetadataUpdate, PluginDownloadContextType } from '../shared';
import { lazyLoader } from '../shared/lazy-loader';
import { DownloadContextFactory } from './download-context.factory';
@@ -26,7 +26,7 @@ export class PluginManager implements IPluginManager {
return this.instance;
}
public async downloadPlugin<U extends { contextType: PluginDownloadContextType; marketplaceId: ID; versionId: ID }>(
public async downloadPlugin<U extends { contextType: PluginDownloadContextType; marketplaceId?: ID; versionId?: ID }>(
config: U
): Promise<IPluginMetadata> {
logger.info(`Downloading plugin...`);
@@ -39,7 +39,11 @@ export class PluginManager implements IPluginManager {
} else {
/* Install plugin */
await this.installPlugin(
{ ...metadata, marketplaceId: config.marketplaceId, versionId: config.versionId },
{
...metadata,
marketplaceId: config.marketplaceId || null,
versionId: config.versionId || null
},
pathDirname
);
}
@@ -82,12 +86,21 @@ export class PluginManager implements IPluginManager {
}
}
// Update plugin marketplace metadata
public async completeInstallation(marketplaceId: string, installationId: string): Promise<void> {
await this.pluginMetadataService.update({
marketplaceId,
installationId
});
// Update plugin marketplace metadata; falls back to plugin name for locally installed plugins
public async completeInstallation(marketplaceId: string | null, installationId: string, name?: string): Promise<void> {
if (!marketplaceId && !name) {
logger.warn(
`completeInstallation: either marketplaceId or name is required to identify the record; skipping update for installationId: ${installationId}`
);
return;
}
const updateData: IPluginMetadataUpdate = { installationId };
if (marketplaceId) {
updateData.marketplaceId = marketplaceId;
} else {
updateData.name = name;
}
await this.pluginMetadataService.update(updateData);
}
public async installPlugin(pluginMetadata: IPluginMetadata, pluginDir: string): Promise<void> {
@@ -1,6 +1,7 @@
import { logger } from '@gauzy/desktop-core';
import { createReadStream } from 'fs';
import { createReadStream, createWriteStream } from 'fs';
import * as fs from 'fs/promises';
import { finished } from 'node:stream/promises';
import * as path from 'path';
import * as unzipper from 'unzipper';
import { ILocalDownloadConfig, IPluginDownloadResponse, IPluginDownloadStrategy } from '../../shared';
@@ -8,7 +9,80 @@ import { LoadPluginDialog } from '../dialog/load-plugin.dialog';
export class LocalDownloadStrategy implements IPluginDownloadStrategy {
private static readonly MAX_RETRIES = 3;
/**
* Resolve an archive entry path against a base extraction directory and ensure
* it does not escape the base (Zip Slip protection).
*
* This method rejects:
* - empty/whitespace-only paths
* - absolute or UNC paths
* - paths that contain any ".." segments
* - paths that, after resolution, are outside the base directory
*/
private static safeExtractPath(baseDir: string, entryPath: string): string | null {
// Basic sanity checks.
if (!entryPath || !entryPath.trim()) {
return null;
}
// Archive entries typically use POSIX-style separators, so normalize on "/"
// when looking for traversal segments, regardless of host OS.
const normalizedEntryPath = entryPath.replace(/\\/g, '/');
// Reject any absolute/UNC-like paths outright.
if (path.isAbsolute(normalizedEntryPath)) {
return null;
}
if (process.platform === 'win32') {
// UNC paths such as \\server\share or //server/share
if (
normalizedEntryPath.startsWith('\\\\') ||
normalizedEntryPath.startsWith('//')
) {
return null;
}
// Patterns like "c:/" or "c:\" (drive-absolute) should also be rejected.
if (/^[a-zA-Z]:[\\/]/.test(normalizedEntryPath)) {
return null;
}
}
// Reject any path that contains a ".." segment after normalization.
const segments = normalizedEntryPath.split('/');
if (segments.some((segment) => segment === '..')) {
return null;
}
// Resolve the candidate path against the base directory.
const resolved = path.resolve(baseDir, normalizedEntryPath);
// Normalize base directory and ensure it ends with a path separator so
// that "/tmp/base2" does not match "/tmp/base".
const normalizedBase = path.resolve(baseDir);
const baseWithSep =
normalizedBase.endsWith(path.sep) ? normalizedBase : normalizedBase + path.sep;
// On Windows, comparisons are case-insensitive for paths.
if (process.platform === 'win32') {
const resolvedLower = resolved.toLowerCase();
const baseLower = baseWithSep.toLowerCase();
if (resolvedLower === normalizedBase.toLowerCase() || resolvedLower.startsWith(baseLower)) {
return resolved;
}
} else {
if (resolved === normalizedBase || resolved.startsWith(baseWithSep)) {
return resolved;
}
}
// Path would escape the base directory; treat as unsafe.
return null;
}
private static readonly RETRY_DELAY_MS = 1000;
private static readonly MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB per file
private static readonly MAX_TOTAL_SIZE = 1024 * 1024 * 1024; // 1GB total extraction limit
private static readonly MANIFEST_FILENAME = 'manifest.json';
/**
* Downloads and installs a plugin from a local zip file
@@ -19,26 +93,37 @@ export class LocalDownloadStrategy implements IPluginDownloadStrategy {
async execute<T>(config: T): Promise<IPluginDownloadResponse> {
const { pluginPath } = config as ILocalDownloadConfig;
let zipFilePath: string | null = null;
const tempExtractPath = path.join(pluginPath, `.temp-extract-${Date.now()}`);
try {
zipFilePath = await this.getZipFilePathFromUser();
await this.validateZipFile(zipFilePath);
const fileName = path.basename(zipFilePath);
const tempDirPath = await this.unzip(zipFilePath, pluginPath);
const pluginDir = path.join(tempDirPath, fileName.replace(/\.zip$/i, ''));
await this.unzip(zipFilePath, tempExtractPath);
const pluginDir = await this.findPluginDirectory(tempExtractPath, path.basename(zipFilePath));
if (!pluginDir) {
throw new Error('Could not find plugin directory in the selected zip');
}
const metadata = await this.readAndValidateManifest(pluginDir);
const pathDirname = await this.createUniquePluginDirectory(pluginPath, pluginDir, metadata.name);
await this.cleanupZipFile(zipFilePath);
// Clean up temp extraction directory (plugin was already moved out)
try {
await fs.rm(tempExtractPath, { recursive: true, force: true });
} catch (cleanupError) {
logger.warn(`Failed to cleanup temp extraction directory: ${tempExtractPath}`);
}
return { pathDirname, metadata };
} catch (error) {
logger.error(`Plugin installation failed: ${error.message}`);
// Cleanup any partially created files
await this.cleanupOnError(zipFilePath);
await this.cleanupOnError(zipFilePath, tempExtractPath);
throw error;
}
}
@@ -66,26 +151,193 @@ export class LocalDownloadStrategy implements IPluginDownloadStrategy {
}
}
private async unzip(filePath: string, extractDir: string): Promise<string> {
/**
* Validates that a file path is safe and doesn't attempt path traversal (Zip Slip prevention)
*/
private isSafePath(rootDir: string, entryPath: string): boolean {
if (!entryPath) return false;
const normalizedEntry = entryPath.replaceAll('\\', '/');
// Reject Unix-style absolute paths
if (normalizedEntry === '/' || normalizedEntry.startsWith('/')) return false;
// Reject Windows absolute paths and drive letters
if (path.isAbsolute(entryPath)) return false;
if (/^[a-zA-Z]:/.test(normalizedEntry)) return false;
// Resolve and verify the target stays within rootDir
const resolvedRoot = path.resolve(rootDir);
const resolvedTarget = path.resolve(rootDir, entryPath);
if (resolvedTarget === resolvedRoot) return false;
if (!resolvedTarget.startsWith(resolvedRoot + path.sep)) return false;
return true;
}
private async unzip(filePath: string, extractDir: string): Promise<void> {
try {
await fs.mkdir(extractDir, { recursive: true });
let totalSize = 0;
await new Promise<void>((resolve, reject) => {
const pendingWrites: Promise<void>[] = [];
createReadStream(filePath)
.pipe(unzipper.Extract({ path: extractDir }))
.on('close', resolve)
.pipe(unzipper.Parse())
.on('entry', (entry: any) => {
const { path: entryPath, type } = entry;
// Security: Zip Slip prevention
const safePath = LocalDownloadStrategy.safeExtractPath(extractDir, entryPath);
if (!safePath) {
logger.warn(`Unsafe archive entry skipped: ${entryPath}`);
return entry.autodrain();
}
if (type === 'Directory') {
// Autodrain synchronously first to avoid backpressure,
// then push only the mkdir promise.
entry.autodrain();
pendingWrites.push(fs.mkdir(safePath, { recursive: true }).then(() => void 0));
return;
}
// File extraction with actual byte-count enforcement.
// `entry.size` from ZIP metadata may be 0 or the compressed size,
// so we count bytes via data events instead.
pendingWrites.push(
(async () => {
let writeStream: ReturnType<typeof createWriteStream> | null = null;
let bytesWritten = 0;
let limitReached = false;
try {
await fs.mkdir(path.dirname(safePath), { recursive: true });
writeStream = createWriteStream(safePath);
// Attach counter before piping so every chunk is measured.
entry.on('data', (chunk: Buffer) => {
if (limitReached) return;
bytesWritten += chunk.length;
totalSize += chunk.length;
if (bytesWritten > LocalDownloadStrategy.MAX_FILE_SIZE) {
limitReached = true;
logger.warn(`File size limit exceeded (actual bytes): ${entryPath}`);
entry.unpipe(writeStream);
if (writeStream && !writeStream.writableEnded) writeStream.destroy();
reject(
new Error(
`File too large: ${entryPath} exceeds ${LocalDownloadStrategy.MAX_FILE_SIZE} bytes`
)
);
return;
}
if (totalSize > LocalDownloadStrategy.MAX_TOTAL_SIZE) {
limitReached = true;
logger.warn(`Total extraction size exceeded at: ${entryPath}`);
entry.unpipe(writeStream);
if (writeStream && !writeStream.writableEnded) writeStream.destroy();
reject(
new Error(
`Total extraction size exceeded (${LocalDownloadStrategy.MAX_TOTAL_SIZE} bytes)`
)
);
return;
}
});
entry.pipe(writeStream);
await finished(writeStream);
} catch (err) {
if (!limitReached) {
// Unpipe entry from writeStream and destroy writeStream
// properly so the error propagates and the pipe is cleaned up.
if (writeStream) entry.unpipe(writeStream);
if (writeStream && !writeStream.writableEnded) writeStream.destroy(err);
try {
await fs.unlink(safePath);
} catch {}
}
throw err;
}
})()
);
})
.on('close', async () => {
try {
await Promise.all(pendingWrites);
resolve();
} catch (err) {
reject(err);
}
})
.on('error', reject);
});
logger.info('File unzipped successfully');
return extractDir;
logger.info(`File unzipped successfully (total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB)`);
} catch (error) {
throw new Error(`Failed to unzip file: ${error.message}`);
}
}
private async findPluginDirectory(basePath: string, zipFileName: string): Promise<string | null> {
try {
// Try the expected directory name first (zip filename without extension)
const expectedDir = path.join(basePath, zipFileName.replace(/\.zip$/i, ''));
try {
await fs.access(expectedDir);
return expectedDir;
} catch {
// Fall back to searching for manifest.json
return await this.searchForManifest(basePath);
}
} catch (error) {
logger.error(`Error finding plugin directory: ${error.message}`);
return null;
}
}
private async searchForManifest(startPath: string): Promise<string | null> {
const results: string[] = [];
const search = async (currentDir: string, depth: number): Promise<boolean> => {
if (depth > 3) return false;
try {
const files = await fs.readdir(currentDir);
for (const file of files) {
const fullPath = path.join(currentDir, file);
const stat = await fs.stat(fullPath);
if (stat.isDirectory()) {
const found = await search(fullPath, depth + 1);
if (found) return true;
} else if (file === LocalDownloadStrategy.MANIFEST_FILENAME) {
results.push(fullPath);
return true;
}
}
} catch (error) {
logger.warn(`Error searching directory ${currentDir}: ${error.message}`);
}
return false;
};
await search(startPath, 0);
if (results.length === 0) {
throw new Error(`No ${LocalDownloadStrategy.MANIFEST_FILENAME} found in the zip file`);
}
return path.dirname(results[0]);
}
private async readAndValidateManifest(pluginDir: string): Promise<any> {
const manifestPath = path.join(pluginDir, 'manifest.json');
const manifestPath = path.join(pluginDir, LocalDownloadStrategy.MANIFEST_FILENAME);
try {
const manifestContent = await fs.readFile(manifestPath, { encoding: 'utf8' });
@@ -133,11 +385,18 @@ export class LocalDownloadStrategy implements IPluginDownloadStrategy {
}
}
private async cleanupOnError(zipFilePath: string | null): Promise<void> {
private async cleanupOnError(zipFilePath: string | null, tempExtractPath?: string): Promise<void> {
try {
if (zipFilePath) {
await this.cleanupZipFile(zipFilePath);
}
if (tempExtractPath) {
try {
await fs.rm(tempExtractPath, { recursive: true, force: true });
} catch (error) {
logger.warn(`Failed to cleanup temp extraction directory: ${tempExtractPath}`);
}
}
} catch (cleanupError) {
logger.warn(`Cleanup failed: ${cleanupError.message}`);
}
@@ -178,13 +178,13 @@ class ElectronPluginListener {
private async syncMarketplaceInstallationId(
event: IpcMainEvent,
{ marketplaceId, installationId }: { marketplaceId: string; installationId: string }
{ marketplaceId, installationId, name }: { marketplaceId: string | null; installationId: string; name?: string }
): Promise<void> {
event.reply(PluginChannel.STATUS, {
status: 'inProgress',
message: 'Updating Plugin Marketplace Installation ID...'
});
await this.pluginManager.completeInstallation(marketplaceId, installationId);
await this.pluginManager.completeInstallation(marketplaceId, installationId, name);
event.reply(PluginChannel.STATUS, { status: 'success', message: 'Installation completed' });
}
@@ -9,7 +9,7 @@ export interface IPluginManager {
downloadPlugin(config: any): Promise<IPluginMetadata>;
activatePlugin(name: string): Promise<void>;
deactivatePlugin(name: string): Promise<void>;
completeInstallation(marketplaceId: ID, installationId: string): Promise<void>;
completeInstallation(marketplaceId: ID | null, installationId: string, name?: string): Promise<void>;
uninstallPlugin(input: IPluginMetadataFindOne): Promise<ID>;
getAllPlugins(): Promise<IPluginMetadata[]>;
getOnePlugin(name: string): Promise<IPluginMetadata>;
@@ -5,6 +5,7 @@ import { catchError, EMPTY, filter, finalize, from, map, switchMap, tap } from '
import { ToastrNotificationService } from '../../../../services';
import { PluginAccessSyncService } from '../../services/plugin-access-sync.service';
import { PluginElectronService } from '../../services/plugin-electron.service';
import type { IPlugin } from '../../services/plugin-loader.service';
import { PluginSubscriptionAccessService } from '../../services/plugin-subscription-access.service';
import { PluginService } from '../../services/plugin.service';
import { PluginActions } from './plugin.action';
@@ -77,6 +78,11 @@ export class PluginEffects {
ofType(PluginActions.activate),
tap(() => this.pluginStore.update({ activating: true })),
switchMap(({ plugin }) => {
// Local plugin — no marketplace, no access check, no server call.
if (!plugin.marketplaceId && !plugin.installationId) {
return this.handleLocalPluginFlow(plugin, 'activate');
}
// Gate: verify user has access before activation
return this.accessService.checkAccess(plugin.marketplaceId).pipe(
switchMap((access) => {
@@ -127,6 +133,12 @@ export class PluginEffects {
ofType(PluginActions.deactivate),
tap(() => this.pluginStore.update({ deactivating: true })),
switchMap(({ plugin }) => {
// Local plugin — no marketplace, no server call needed.
// Both fields must be absent (see activate$ comment).
if (!plugin.marketplaceId && !plugin.installationId) {
return this.handleLocalPluginFlow(plugin, 'deactivate');
}
// First check with server-side to validate plugin can be deactivated
return this.pluginService.deactivate(plugin.marketplaceId, plugin.installationId).pipe(
switchMap(() => {
@@ -201,6 +213,29 @@ export class PluginEffects {
)
);
/**
* Shared handler for local (non-marketplace) plugin activate/deactivate flows.
* Centralises progress wiring, store flag reset, and error handling to avoid duplication.
*/
private handleLocalPluginFlow(plugin: IPlugin, action: 'activate' | 'deactivate') {
if (action === 'activate') {
this.pluginElectronService.activate(plugin);
} else {
this.pluginElectronService.deactivate(plugin);
}
const storeFlag = action === 'activate' ? { activating: false } : { deactivating: false };
return this.pluginElectronService
.progress((message) => this.toastrService.info(message))
.pipe(
tap((res) => this.handleProgress(res)),
finalize(() => this.pluginStore.update(storeFlag)),
catchError((error) => {
this.toastrService.error(error);
return EMPTY;
})
);
}
private handleProgress(arg: { message?: string }): void {
this.toastrService.success(arg?.message);
this.action$.dispatch(PluginActions.selectPlugin(null));
@@ -14,7 +14,12 @@ export class PluginInstallationActions {
public static startDownload = createAction('[Plugin Installation] Start Download', <T>(config: T) => ({ config }));
public static downloadCompleted = createAction(
'[Plugin Installation] Download Completed',
(plugin: IPlugin, message?: string) => ({ plugin, message })
(plugin: IPlugin, message?: string, contextType?: string, localInstallId?: string) => ({
plugin,
message,
contextType,
localInstallId
})
);
public static downloadFailed = createAction('[Plugin Installation] Download Failed', (error: string, pluginId?: string) => ({
error,
@@ -52,16 +57,21 @@ export class PluginInstallationActions {
// Step 4: Activation
public static startActivation = createAction(
'[Plugin Installation] Start Activation',
(installationId: string, marketplaceId: string) => ({ installationId, marketplaceId })
(installationId: string | null, marketplaceId: string | null, name?: string, localInstallId?: string) => ({
installationId,
marketplaceId,
name,
localInstallId
})
);
public static activationCompleted = createAction(
'[Plugin Installation] Activation Completed',
(plugin: IPlugin, message?: string) => ({ plugin, message })
(plugin: IPlugin, message?: string, localInstallId?: string) => ({ plugin, message, localInstallId })
);
public static activationFailed = createAction(
'[Plugin Installation] Activation Failed',
(error: string, pluginId?: string, localInstallId?: string) => ({ error, pluginId, localInstallId })
);
public static activationFailed = createAction('[Plugin Installation] Activation Failed', (error: string, pluginId?: string) => ({
error,
pluginId
}));
// Cleanup
public static clearError = createAction('[Plugin Installation] Clear Error', (pluginId?: string) => ({ pluginId }));
@@ -206,6 +206,12 @@ export class PluginInstallationEffects {
if (pluginId) {
this.pluginInstallationStore.setInstalling(pluginId, true);
this.pluginInstallationStore.setErrorMessage(pluginId, null);
} else {
// Generate a unique ID per local install to avoid key collisions
// for concurrent local installations.
const localInstallId = crypto.randomUUID();
(config as Record<string, unknown>)['_localInstallId'] = localInstallId;
this.pluginInstallationStore.setInstalling(localInstallId, true);
}
// Dispatch download start action to trigger download effect
return PluginInstallationActions.startDownload(config);
@@ -237,12 +243,24 @@ export class PluginInstallationEffects {
message || this.translateService.instant('PLUGIN.TOASTR.INFO.DOWNLOAD_COMPLETED')
);
}),
map(({ plugin, message }) => PluginInstallationActions.downloadCompleted(plugin, message)),
map(({ plugin, message }) =>
PluginInstallationActions.downloadCompleted(
plugin,
message,
config?.['contextType'],
config?.['_localInstallId'] as string | undefined
)
),
finalize(() => {
const pluginId = config?.['marketplaceId'];
if (pluginId) {
this.pluginInstallationStore.setDownloading(pluginId, false);
}
// Clear localInstallId here so that switchMap cancellation
const localInstallId = config?.['_localInstallId'] as string | undefined;
if (localInstallId) {
this.pluginInstallationStore.setInstalling(localInstallId, false);
}
}),
catchError((error) => {
const pluginId = config?.['marketplaceId'];
@@ -261,20 +279,37 @@ export class PluginInstallationEffects {
/**
* Step 2: Server Installation Effect
* Single Responsibility: Only handles server-side installation
* For local installations (without marketplace metadata), skip server installation
*/
serverInstallPlugin$ = createEffect(
() =>
this.action$.pipe(
ofType(PluginInstallationActions.downloadCompleted),
map(({ plugin }) => {
map(({ plugin, contextType, localInstallId }) => {
const { marketplaceId: pluginId, versionId } = plugin || {};
// For marketplace plugins, proceed with server installation
if (pluginId && versionId) {
return PluginInstallationActions.startServerInstallation(pluginId, versionId);
} else {
return PluginInstallationActions.downloadFailed(
this.translateService.instant('PLUGIN.TOASTR.ERROR.INVALID_PLUGIN_DATA')
}
// For local/direct installations: the contextType must be explicitly 'local',
// the DB record must have no marketplaceId, and a plugin name must be present.
// All three conditions together prevent marketplace plugins from bypassing
// access checks through the local installation path.
if (contextType === 'local' && !pluginId && plugin?.name) {
return PluginInstallationActions.startActivation(
plugin.installationId || null,
null,
plugin.name,
localInstallId
);
}
// Clear the local install loading key before dispatching failure
if (localInstallId) {
this.pluginInstallationStore.setInstalling(localInstallId, false);
}
return PluginInstallationActions.downloadFailed(
this.translateService.instant('PLUGIN.TOASTR.ERROR.INVALID_PLUGIN_DATA')
);
})
),
{
@@ -410,29 +445,39 @@ export class PluginInstallationEffects {
/**
* Execute plugin activation
* Handles both marketplace and local installations
*/
executeActivation$ = createEffect(
() =>
this.action$.pipe(
ofType(PluginInstallationActions.startActivation),
tap(({ marketplaceId }) => this.pluginInstallationStore.setActivating(marketplaceId, true)),
switchMap(({ installationId, marketplaceId }) => {
return this.activateCommand.execute({ marketplaceId, installationId }).pipe(
tap(({ marketplaceId }) => {
if (marketplaceId) {
this.pluginInstallationStore.setActivating(marketplaceId, true);
}
}),
switchMap(({ installationId, marketplaceId, name, localInstallId }) => {
return this.activateCommand.execute({ marketplaceId, installationId, name }).pipe(
tap(({ message }) => {
this.toastrService.success(
message || this.translateService.instant('PLUGIN.TOASTR.SUCCESS.ACTIVATION_COMPLETED')
);
}),
map(({ plugin, message }) => PluginInstallationActions.activationCompleted(plugin, message)),
map(({ plugin, message }) =>
PluginInstallationActions.activationCompleted(plugin, message, localInstallId)
),
finalize(() => {
this.pluginInstallationStore.setActivating(marketplaceId, false);
this.pluginInstallationStore.setInstalling(marketplaceId, false);
if (marketplaceId) {
this.pluginInstallationStore.setActivating(marketplaceId, false);
this.pluginInstallationStore.setInstalling(marketplaceId, false);
}
}),
catchError((error) =>
of(
PluginInstallationActions.activationFailed(
error?.message || 'Activation failed',
marketplaceId
marketplaceId,
localInstallId
)
)
)
@@ -446,18 +491,30 @@ export class PluginInstallationEffects {
/**
* Finalize installation after successful activation
* For local installations without marketplaceId, skip marketplace-specific actions
*/
finalizeInstallation$ = createEffect(
() =>
this.action$.pipe(
ofType(PluginInstallationActions.activationCompleted),
concatMap(({ plugin: { marketplaceId } }) => {
concatMap(({ plugin: { marketplaceId }, localInstallId }) => {
this.handleSuccess('Installation done', marketplaceId);
return [
PluginToggleActions.toggle({ pluginId: marketplaceId, enabled: true }),
type DispatchableAction =
| ReturnType<typeof PluginActions.selectPlugin>
| ReturnType<typeof PluginActions.refresh>
| ReturnType<typeof PluginToggleActions.toggle>;
const actions: DispatchableAction[] = [
PluginActions.selectPlugin(null),
PluginActions.refresh()
];
// Only toggle for marketplace plugins
if (marketplaceId) {
actions.unshift(PluginToggleActions.toggle({ pluginId: marketplaceId, enabled: true }));
} else if (localInstallId) {
// Clear the unique local install loading key generated at install start
this.pluginInstallationStore.setInstalling(localInstallId, false);
}
return actions;
})
),
{
@@ -478,6 +535,8 @@ export class PluginInstallationEffects {
this.pluginInstallationStore.setDownloading(pluginId, false);
this.pluginInstallationStore.setErrorMessage(pluginId, error);
}
// For local installs the unique loading key is cleared directly in the
// downloadPlugin$ catchError, so no additional cleanup is needed here.
this.toastrService.error(
error || this.translateService.instant('PLUGIN.TOASTR.ERROR.DOWNLOAD_FAILED')
);
@@ -563,11 +622,14 @@ export class PluginInstallationEffects {
() =>
this.action$.pipe(
ofType(PluginInstallationActions.activationFailed),
concatMap(({ error, pluginId }) => {
concatMap(({ error, pluginId, localInstallId }) => {
if (pluginId) {
this.pluginInstallationStore.setInstalling(pluginId, false);
this.pluginInstallationStore.setActivating(pluginId, false);
this.pluginInstallationStore.setErrorMessage(pluginId, error);
} else if (localInstallId) {
// Clear the unique local install loading key on activation failure
this.pluginInstallationStore.setInstalling(localInstallId, false);
}
this.toastrService.error(
error || this.translateService.instant('PLUGIN.TOASTR.ERROR.ACTIVATION_FAILED')
@@ -8,10 +8,13 @@ import { IInstallationCommand } from '../interfaces';
/**
* Parameters for activate plugin command
* marketplaceId can be null for local/direct installations
*/
export interface IActivatePluginCommandParams {
marketplaceId: string;
marketplaceId: string | null;
installationId?: string;
/** Plugin name, used to look up locally installed plugins when marketplaceId is absent */
name?: string;
}
/**
@@ -42,11 +45,55 @@ export class ActivatePluginCommand
/**
* Executes the activate plugin command
* For local installations (marketplaceId is null), skip server validation and access checks
*/
public execute(params: IActivatePluginCommandParams): Observable<IActivatePluginCommandResult> {
const { marketplaceId, installationId } = params;
const { marketplaceId, installationId, name } = params;
// Gate: verify user has access before proceeding with activation
// For local installations, skip marketplace-specific checks
if (!marketplaceId) {
// name is the only reliable look-up key for local plugins.
// installationId is a backend DB record ID, not a marketplaceId, so it
// cannot be passed to checkInstallation (which expects a marketplaceId).
if (!name) {
return throwError(
() =>
new Error(
'Cannot activate plugin: plugin name must be provided for local installations.'
)
);
}
// Direct local activation without server validation.
return from(this.pluginElectronService.plugin(name)).pipe(
switchMap((plugin) => {
// Guard against null/undefined plugin before proceeding.
if (!plugin) {
return throwError(() => new Error('Plugin not found'));
}
// SECURITY GUARD: Even though the caller did not supply a marketplaceId,
if (plugin?.marketplaceId) {
return throwError(
() =>
new Error(
'This plugin is registered in the marketplace and must be activated through the marketplace.'
)
);
}
this.pluginElectronService.activate(plugin);
return this.pluginElectronService.progress<void, IPlugin>().pipe(
map(({ data, message }) => ({
success: true,
plugin: data || plugin,
message
}))
);
})
);
}
// For marketplace installations, verify access and validate with server
return this.accessService.checkAccess(marketplaceId).pipe(
switchMap((access) => {
if (!access.hasAccess) {