mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 10:45:57 +00:00
Enable plugins to access the Node.js 'os' module for system information gathering alongside the existing fs and path modules. Changes: - Updated canExecuteDirectly() regex pattern to allow 'os' module imports - Added os module import to executeDirectly() sandbox environment - Extended sandbox require() function to provide access to os module This allows plugins to access system information like platform, architecture, memory usage, CPU info, and network interfaces through the standard Node.js os module while maintaining the existing security restrictions. The os module is considered safe as it provides read-only system information and doesn't allow file system modifications or process execution.
245 lines
6.9 KiB
TypeScript
245 lines
6.9 KiB
TypeScript
import { BrowserWindow, ipcMain } from 'electron';
|
|
import { spawn } from 'child_process';
|
|
import * as vm from 'vm';
|
|
import { IPC } from './shared-with-frontend/ipc-events.const';
|
|
import {
|
|
PluginNodeScriptRequest,
|
|
PluginNodeScriptResult,
|
|
PluginManifest,
|
|
} from '../packages/plugin-api/src/types';
|
|
|
|
const DEFAULT_TIMEOUT = 30000; // 30 seconds
|
|
const MAX_TIMEOUT = 300000; // 5 minutes
|
|
|
|
class PluginNodeExecutor {
|
|
constructor() {
|
|
this.setupIpcHandler();
|
|
}
|
|
|
|
private setupIpcHandler(): void {
|
|
ipcMain.handle(
|
|
IPC.PLUGIN_EXEC_NODE_SCRIPT,
|
|
async (
|
|
event,
|
|
pluginId: string,
|
|
manifest: PluginManifest,
|
|
request: PluginNodeScriptRequest,
|
|
) => {
|
|
const window = BrowserWindow.fromWebContents(event.sender);
|
|
if (!window) {
|
|
throw new Error('No window found for event sender');
|
|
}
|
|
|
|
// Check permissions
|
|
if (!manifest.permissions?.includes('nodeExecution')) {
|
|
throw new Error('Plugin does not have nodeExecution permission');
|
|
}
|
|
|
|
return await this.executeScript(pluginId, request);
|
|
},
|
|
);
|
|
}
|
|
|
|
private async executeScript(
|
|
pluginId: string,
|
|
request: PluginNodeScriptRequest,
|
|
): Promise<PluginNodeScriptResult> {
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
// Validate request
|
|
this.validateScriptRequest(request);
|
|
|
|
// Try direct execution first (faster, safer)
|
|
if (this.canExecuteDirectly(request.script)) {
|
|
const result = await this.executeDirectly(request.script, request.args);
|
|
return {
|
|
success: true,
|
|
result,
|
|
executionTime: Date.now() - startTime,
|
|
};
|
|
}
|
|
|
|
// For complex scripts, use spawned process
|
|
const result = await this.executeViaSpawn(
|
|
request.script,
|
|
request.args,
|
|
request.timeout,
|
|
);
|
|
return {
|
|
success: true,
|
|
result,
|
|
executionTime: Date.now() - startTime,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
executionTime: Date.now() - startTime,
|
|
};
|
|
}
|
|
}
|
|
|
|
private validateScriptRequest(request: PluginNodeScriptRequest): void {
|
|
if (!request.script || typeof request.script !== 'string') {
|
|
throw new Error('Script must be a non-empty string');
|
|
}
|
|
|
|
if (request.script.length > 100000) {
|
|
throw new Error('Script too large (max 100KB)');
|
|
}
|
|
|
|
if (request.timeout !== undefined) {
|
|
if (typeof request.timeout !== 'number' || request.timeout < 0) {
|
|
throw new Error('Timeout must be a positive number');
|
|
}
|
|
if (request.timeout > MAX_TIMEOUT) {
|
|
throw new Error(`Timeout exceeds maximum allowed (${MAX_TIMEOUT}ms)`);
|
|
}
|
|
}
|
|
}
|
|
|
|
private canExecuteDirectly(script: string): boolean {
|
|
// Check if script only uses safe operations
|
|
const dangerousPatterns =
|
|
/require\s*\(\s*['"`](?!fs|path|os)[^'"]+['"`]\s*\)|child_process|exec|spawn|eval|Function|process\.exit/;
|
|
return !dangerousPatterns.test(script);
|
|
}
|
|
|
|
private async executeDirectly(script: string, args?: unknown[]): Promise<unknown> {
|
|
// Safe modules
|
|
const fs = await import('fs');
|
|
const path = await import('path');
|
|
const os = await import('os');
|
|
|
|
// Create sandboxed context
|
|
const sandbox = {
|
|
require: (module: string) => {
|
|
if (module === 'fs') return fs;
|
|
if (module === 'path') return path;
|
|
if (module === 'os') return os;
|
|
throw new Error(`Module '${module}' is not allowed`);
|
|
},
|
|
console: {
|
|
log: (...logArgs: any[]) => console.log('[Plugin]:', ...logArgs),
|
|
error: (...errorArgs: any[]) => console.error('[Plugin]:', ...errorArgs),
|
|
},
|
|
JSON,
|
|
args: args || [],
|
|
__result: undefined,
|
|
};
|
|
|
|
// Execute in VM with timeout
|
|
const context = vm.createContext(sandbox);
|
|
const script_wrapped = `
|
|
(async function() {
|
|
const result = await (async function() {
|
|
${script}
|
|
})();
|
|
__result = result;
|
|
})().catch(err => { throw err; });
|
|
`;
|
|
|
|
await vm.runInContext(script_wrapped, context, {
|
|
timeout: 5000, // 5 second timeout for direct execution
|
|
});
|
|
|
|
return sandbox.__result;
|
|
}
|
|
|
|
private async executeViaSpawn(
|
|
script: string,
|
|
args?: unknown[],
|
|
timeout?: number,
|
|
): Promise<unknown> {
|
|
return new Promise((resolve, reject) => {
|
|
const timeoutMs = Math.min(timeout || DEFAULT_TIMEOUT, MAX_TIMEOUT);
|
|
|
|
// Wrap script for security
|
|
const wrappedScript = `
|
|
'use strict';
|
|
(async function() {
|
|
const args = ${JSON.stringify(args || [])};
|
|
try {
|
|
const result = await (async function() {
|
|
${script}
|
|
})();
|
|
console.log(JSON.stringify({ __result: result }));
|
|
} catch (error) {
|
|
console.error(JSON.stringify({
|
|
__error: error.message || String(error)
|
|
}));
|
|
process.exit(1);
|
|
}
|
|
})();
|
|
`;
|
|
|
|
// Use electron's node or system node
|
|
const nodePath = process.execPath.includes('electron') ? process.execPath : 'node';
|
|
|
|
// Spawn process with script via -e flag (no temp files!)
|
|
const child = spawn(nodePath, ['--no-warnings', '-e', wrappedScript], {
|
|
env: {
|
|
NODE_ENV: 'production',
|
|
ELECTRON_RUN_AS_NODE: '1',
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let killed = false;
|
|
|
|
// Timeout
|
|
const timer = setTimeout(() => {
|
|
killed = true;
|
|
child.kill('SIGTERM');
|
|
// Force kill after a short delay if process doesn't terminate
|
|
setTimeout(() => {
|
|
if (!child.killed) {
|
|
child.kill('SIGKILL');
|
|
}
|
|
}, 1000);
|
|
reject(new Error(`Script execution timed out after ${timeoutMs}ms`));
|
|
}, timeoutMs);
|
|
|
|
child.stdout.on('data', (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
|
|
child.stderr.on('data', (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
|
|
child.on('error', (err) => {
|
|
clearTimeout(timer);
|
|
reject(new Error('Failed to execute script: ' + err.message));
|
|
});
|
|
|
|
child.on('close', (code) => {
|
|
clearTimeout(timer);
|
|
|
|
if (killed) return;
|
|
|
|
try {
|
|
if (stdout) {
|
|
const parsed = JSON.parse(stdout.trim());
|
|
if (parsed.__error) {
|
|
reject(new Error(parsed.__error));
|
|
} else {
|
|
resolve(parsed.__result);
|
|
}
|
|
} else if (code !== 0) {
|
|
reject(new Error(stderr || `Process exited with code ${code}`));
|
|
} else {
|
|
resolve(undefined);
|
|
}
|
|
} catch (e) {
|
|
reject(new Error(`Failed to parse output: ${e}`));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
export const pluginNodeExecutor = new PluginNodeExecutor();
|