fix: track plugin-host source and add 35 sandbox security tests
Fix .gitignore 'plugins/' rule that was accidentally ignoring source files in v2/src/lib/plugins/. Narrow to /plugins/ and /v2/plugins/ (runtime plugin directories only). Track plugin-host.ts (was written but never committed) and add comprehensive test suite covering all 13 shadowed globals, this-binding, permission gating, API freeze, and lifecycle management.
This commit is contained in:
parent
e46b9e06d1
commit
8754b64ee3
3 changed files with 588 additions and 1 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,7 +4,8 @@ __pycache__/
|
|||
/CLAUDE.md
|
||||
v2/target/
|
||||
debug/
|
||||
plugins/
|
||||
/plugins/
|
||||
/v2/plugins/
|
||||
projects/
|
||||
.playwright-mcp/
|
||||
.audit/
|
||||
|
|
|
|||
373
v2/src/lib/plugins/plugin-host.test.ts
Normal file
373
v2/src/lib/plugins/plugin-host.test.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
const { mockInvoke } = vi.hoisted(() => ({
|
||||
mockInvoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: mockInvoke,
|
||||
}));
|
||||
|
||||
// Mock the plugins store to avoid Svelte 5 rune issues in test context
|
||||
vi.mock('../stores/plugins.svelte', () => {
|
||||
const commands: Array<{ pluginId: string; label: string; callback: () => void }> = [];
|
||||
return {
|
||||
addPluginCommand: vi.fn((pluginId: string, label: string, callback: () => void) => {
|
||||
commands.push({ pluginId, label, callback });
|
||||
}),
|
||||
removePluginCommands: vi.fn((pluginId: string) => {
|
||||
const toRemove = commands.filter(c => c.pluginId === pluginId);
|
||||
for (const cmd of toRemove) {
|
||||
const idx = commands.indexOf(cmd);
|
||||
if (idx >= 0) commands.splice(idx, 1);
|
||||
}
|
||||
}),
|
||||
pluginEventBus: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
},
|
||||
getPluginCommands: () => [...commands],
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
loadPlugin,
|
||||
unloadPlugin,
|
||||
getLoadedPlugins,
|
||||
unloadAllPlugins,
|
||||
} from './plugin-host';
|
||||
import { addPluginCommand, removePluginCommands } from '../stores/plugins.svelte';
|
||||
import type { PluginMeta } from '../adapters/plugins-bridge';
|
||||
import type { GroupId, AgentId } from '../types/ids';
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function makeMeta(overrides: Partial<PluginMeta> = {}): PluginMeta {
|
||||
return {
|
||||
id: overrides.id ?? 'test-plugin',
|
||||
name: overrides.name ?? 'Test Plugin',
|
||||
version: overrides.version ?? '1.0.0',
|
||||
description: overrides.description ?? 'A test plugin',
|
||||
main: overrides.main ?? 'index.js',
|
||||
permissions: overrides.permissions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Set mockInvoke to return the given code when plugin_read_file is called */
|
||||
function mockPluginCode(code: string): void {
|
||||
mockInvoke.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'plugin_read_file') return Promise.resolve(code);
|
||||
return Promise.reject(new Error(`Unexpected invoke: ${cmd}`));
|
||||
});
|
||||
}
|
||||
|
||||
const GROUP_ID = 'test-group' as GroupId;
|
||||
const AGENT_ID = 'test-agent' as AgentId;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
unloadAllPlugins();
|
||||
});
|
||||
|
||||
// --- Sandbox escape prevention tests ---
|
||||
|
||||
describe('plugin-host sandbox', () => {
|
||||
describe('global shadowing', () => {
|
||||
// `eval` is intentionally excluded: `var eval` is a SyntaxError in strict mode.
|
||||
// eval() itself is neutered in strict mode (cannot inject into calling scope).
|
||||
const shadowedGlobals = [
|
||||
'window',
|
||||
'document',
|
||||
'fetch',
|
||||
'globalThis',
|
||||
'self',
|
||||
'XMLHttpRequest',
|
||||
'WebSocket',
|
||||
'Function',
|
||||
'importScripts',
|
||||
'require',
|
||||
'process',
|
||||
'Deno',
|
||||
'__TAURI__',
|
||||
'__TAURI_INTERNALS__',
|
||||
];
|
||||
|
||||
for (const name of shadowedGlobals) {
|
||||
it(`shadows '${name}' as undefined`, async () => {
|
||||
const meta = makeMeta({ id: `shadow-${name}` });
|
||||
const code = `
|
||||
if (typeof ${name} !== 'undefined') {
|
||||
throw new Error('ESCAPE: ${name} is accessible');
|
||||
}
|
||||
`;
|
||||
mockPluginCode(code);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('this binding', () => {
|
||||
it('this is undefined in strict mode (cannot reach global scope)', async () => {
|
||||
const meta = makeMeta({ id: 'this-test' });
|
||||
mockPluginCode(`
|
||||
if (this !== undefined) {
|
||||
throw new Error('ESCAPE: this is not undefined, got: ' + typeof this);
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime-level shadowing', () => {
|
||||
it('require is shadowed (blocks CJS imports)', async () => {
|
||||
const meta = makeMeta({ id: 'require-test' });
|
||||
mockPluginCode(`
|
||||
if (typeof require !== 'undefined') {
|
||||
throw new Error('ESCAPE: require is accessible');
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('process is shadowed (blocks env access)', async () => {
|
||||
const meta = makeMeta({ id: 'process-test' });
|
||||
mockPluginCode(`
|
||||
if (typeof process !== 'undefined') {
|
||||
throw new Error('ESCAPE: process is accessible');
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('Deno is shadowed', async () => {
|
||||
const meta = makeMeta({ id: 'deno-test' });
|
||||
mockPluginCode(`
|
||||
if (typeof Deno !== 'undefined') {
|
||||
throw new Error('ESCAPE: Deno is accessible');
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tauri IPC shadowing', () => {
|
||||
it('__TAURI__ is shadowed (blocks Tauri IPC bridge)', async () => {
|
||||
const meta = makeMeta({ id: 'tauri-test' });
|
||||
mockPluginCode(`
|
||||
if (typeof __TAURI__ !== 'undefined') {
|
||||
throw new Error('ESCAPE: __TAURI__ is accessible');
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('__TAURI_INTERNALS__ is shadowed', async () => {
|
||||
const meta = makeMeta({ id: 'tauri-internals-test' });
|
||||
mockPluginCode(`
|
||||
if (typeof __TAURI_INTERNALS__ !== 'undefined') {
|
||||
throw new Error('ESCAPE: __TAURI_INTERNALS__ is accessible');
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Permission-gated API tests ---
|
||||
|
||||
describe('plugin-host permissions', () => {
|
||||
describe('palette permission', () => {
|
||||
it('plugin with palette permission can register commands', async () => {
|
||||
const meta = makeMeta({ id: 'palette-plugin', permissions: ['palette'] });
|
||||
mockPluginCode(`
|
||||
bterminal.palette.registerCommand('Test Command', function() {});
|
||||
`);
|
||||
|
||||
await loadPlugin(meta, GROUP_ID, AGENT_ID);
|
||||
|
||||
expect(addPluginCommand).toHaveBeenCalledWith(
|
||||
'palette-plugin',
|
||||
'Test Command',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('plugin without palette permission has no palette API', async () => {
|
||||
const meta = makeMeta({ id: 'no-palette-plugin', permissions: [] });
|
||||
mockPluginCode(`
|
||||
if (bterminal.palette !== undefined) {
|
||||
throw new Error('palette API should not be available');
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('palette.registerCommand rejects non-string label', async () => {
|
||||
const meta = makeMeta({ id: 'bad-label-plugin', permissions: ['palette'] });
|
||||
mockPluginCode(`
|
||||
bterminal.palette.registerCommand(123, function() {});
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
|
||||
'execution failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('palette.registerCommand rejects non-function callback', async () => {
|
||||
const meta = makeMeta({ id: 'bad-cb-plugin', permissions: ['palette'] });
|
||||
mockPluginCode(`
|
||||
bterminal.palette.registerCommand('Test', 'not-a-function');
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
|
||||
'execution failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('palette.registerCommand rejects empty label', async () => {
|
||||
const meta = makeMeta({ id: 'empty-label-plugin', permissions: ['palette'] });
|
||||
mockPluginCode(`
|
||||
bterminal.palette.registerCommand(' ', function() {});
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
|
||||
'execution failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API object is frozen', () => {
|
||||
it('cannot add properties to bterminal', async () => {
|
||||
const meta = makeMeta({ id: 'freeze-test', permissions: [] });
|
||||
// In strict mode, assigning to a frozen object throws TypeError
|
||||
mockPluginCode(`
|
||||
try {
|
||||
bterminal.hacked = true;
|
||||
throw new Error('FREEZE FAILED: could add property');
|
||||
} catch (e) {
|
||||
if (e.message === 'FREEZE FAILED: could add property') throw e;
|
||||
// TypeError from strict mode + frozen object is expected
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('cannot delete properties from bterminal', async () => {
|
||||
const meta = makeMeta({ id: 'freeze-delete-test', permissions: [] });
|
||||
mockPluginCode(`
|
||||
try {
|
||||
delete bterminal.meta;
|
||||
throw new Error('FREEZE FAILED: could delete property');
|
||||
} catch (e) {
|
||||
if (e.message === 'FREEZE FAILED: could delete property') throw e;
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Lifecycle tests ---
|
||||
|
||||
describe('plugin-host lifecycle', () => {
|
||||
it('loadPlugin registers the plugin', async () => {
|
||||
const meta = makeMeta({ id: 'lifecycle-load' });
|
||||
mockPluginCode('// no-op');
|
||||
|
||||
await loadPlugin(meta, GROUP_ID, AGENT_ID);
|
||||
|
||||
const loaded = getLoadedPlugins();
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0].id).toBe('lifecycle-load');
|
||||
});
|
||||
|
||||
it('loadPlugin warns on duplicate load and returns early', async () => {
|
||||
const meta = makeMeta({ id: 'duplicate-load' });
|
||||
mockPluginCode('// no-op');
|
||||
|
||||
await loadPlugin(meta, GROUP_ID, AGENT_ID);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
await loadPlugin(meta, GROUP_ID, AGENT_ID);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Plugin 'duplicate-load' is already loaded");
|
||||
consoleSpy.mockRestore();
|
||||
|
||||
// Still only one entry
|
||||
expect(getLoadedPlugins()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('unloadPlugin removes the plugin and cleans up commands', async () => {
|
||||
const meta = makeMeta({ id: 'lifecycle-unload', permissions: ['palette'] });
|
||||
mockPluginCode(`
|
||||
bterminal.palette.registerCommand('Cmd1', function() {});
|
||||
`);
|
||||
|
||||
await loadPlugin(meta, GROUP_ID, AGENT_ID);
|
||||
expect(getLoadedPlugins()).toHaveLength(1);
|
||||
|
||||
unloadPlugin('lifecycle-unload');
|
||||
expect(getLoadedPlugins()).toHaveLength(0);
|
||||
expect(removePluginCommands).toHaveBeenCalledWith('lifecycle-unload');
|
||||
});
|
||||
|
||||
it('unloadPlugin is no-op for unknown plugin', () => {
|
||||
unloadPlugin('nonexistent');
|
||||
expect(getLoadedPlugins()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('unloadAllPlugins clears all loaded plugins', async () => {
|
||||
mockPluginCode('// no-op');
|
||||
|
||||
const meta1 = makeMeta({ id: 'all-1' });
|
||||
await loadPlugin(meta1, GROUP_ID, AGENT_ID);
|
||||
|
||||
const meta2 = makeMeta({ id: 'all-2' });
|
||||
await loadPlugin(meta2, GROUP_ID, AGENT_ID);
|
||||
|
||||
expect(getLoadedPlugins()).toHaveLength(2);
|
||||
|
||||
unloadAllPlugins();
|
||||
expect(getLoadedPlugins()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('loadPlugin cleans up commands on execution error', async () => {
|
||||
const meta = makeMeta({ id: 'error-cleanup' });
|
||||
mockPluginCode('throw new Error("plugin crash");');
|
||||
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
|
||||
"Plugin 'error-cleanup' execution failed",
|
||||
);
|
||||
expect(removePluginCommands).toHaveBeenCalledWith('error-cleanup');
|
||||
expect(getLoadedPlugins()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('loadPlugin throws on file read failure', async () => {
|
||||
const meta = makeMeta({ id: 'read-fail' });
|
||||
mockInvoke.mockRejectedValue(new Error('file not found'));
|
||||
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
|
||||
"Failed to read plugin 'read-fail'",
|
||||
);
|
||||
});
|
||||
|
||||
it('plugin meta is accessible and frozen', async () => {
|
||||
const meta = makeMeta({ id: 'meta-access', permissions: [] });
|
||||
mockPluginCode(`
|
||||
if (bterminal.meta.id !== 'meta-access') {
|
||||
throw new Error('meta.id mismatch');
|
||||
}
|
||||
if (bterminal.meta.name !== 'Test Plugin') {
|
||||
throw new Error('meta.name mismatch');
|
||||
}
|
||||
// meta should also be frozen
|
||||
try {
|
||||
bterminal.meta.id = 'hacked';
|
||||
throw new Error('META FREEZE FAILED');
|
||||
} catch (e) {
|
||||
if (e.message === 'META FREEZE FAILED') throw e;
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
213
v2/src/lib/plugins/plugin-host.ts
Normal file
213
v2/src/lib/plugins/plugin-host.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
/**
|
||||
* Plugin Host — sandboxed runtime for BTerminal plugins.
|
||||
*
|
||||
* Plugins run via `new Function()` with a controlled API object (`bterminal`).
|
||||
* Dangerous globals are shadowed via `var` declarations inside strict mode.
|
||||
*
|
||||
* SECURITY BOUNDARY: Best-effort sandbox, NOT a security boundary.
|
||||
* `new Function()` executes in the same JS realm. Known limitations:
|
||||
* - `arguments.callee.constructor('return this')()` can recover the real global
|
||||
* object — this is inherent to `new Function()` and cannot be fully blocked
|
||||
* without a separate realm (iframe, Worker, or wasm-based isolate).
|
||||
* - Prototype chain walking (e.g., `({}).constructor.constructor`) can also
|
||||
* reach Function and thus the global scope.
|
||||
* - Plugins MUST be treated as UNTRUSTED. This sandbox reduces the attack
|
||||
* surface but does not eliminate it. Defense in depth comes from the Rust
|
||||
* backend's Landlock sandbox and permission-gated Tauri commands.
|
||||
*/
|
||||
|
||||
import type { PluginMeta } from '../adapters/plugins-bridge';
|
||||
import { readPluginFile } from '../adapters/plugins-bridge';
|
||||
import { listTasks, getTaskComments } from '../adapters/bttask-bridge';
|
||||
import {
|
||||
getUnreadMessages,
|
||||
getChannels,
|
||||
} from '../adapters/btmsg-bridge';
|
||||
import {
|
||||
addPluginCommand,
|
||||
removePluginCommands,
|
||||
pluginEventBus,
|
||||
} from '../stores/plugins.svelte';
|
||||
import type { GroupId, AgentId } from '../types/ids';
|
||||
|
||||
interface LoadedPlugin {
|
||||
meta: PluginMeta;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
const loadedPlugins = new Map<string, LoadedPlugin>();
|
||||
|
||||
/**
|
||||
* Build the sandboxed API object for a plugin.
|
||||
* Only exposes capabilities matching the plugin's declared permissions.
|
||||
*/
|
||||
function buildPluginAPI(meta: PluginMeta, groupId: GroupId, agentId: AgentId): Record<string, unknown> {
|
||||
const api: Record<string, unknown> = {
|
||||
meta: Object.freeze({ ...meta }),
|
||||
};
|
||||
|
||||
// palette permission — register command palette commands
|
||||
if (meta.permissions.includes('palette')) {
|
||||
api.palette = {
|
||||
registerCommand(label: string, callback: () => void) {
|
||||
if (typeof label !== 'string' || !label.trim()) {
|
||||
throw new Error('Command label must be a non-empty string');
|
||||
}
|
||||
if (typeof callback !== 'function') {
|
||||
throw new Error('Command callback must be a function');
|
||||
}
|
||||
addPluginCommand(meta.id, label, callback);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// bttask:read permission — read-only task access
|
||||
if (meta.permissions.includes('bttask:read')) {
|
||||
api.tasks = {
|
||||
async list() {
|
||||
return listTasks(groupId);
|
||||
},
|
||||
async comments(taskId: string) {
|
||||
return getTaskComments(taskId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// btmsg:read permission — read-only message access
|
||||
if (meta.permissions.includes('btmsg:read')) {
|
||||
api.messages = {
|
||||
async inbox() {
|
||||
return getUnreadMessages(agentId);
|
||||
},
|
||||
async channels() {
|
||||
return getChannels(groupId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// events permission — subscribe to app events
|
||||
if (meta.permissions.includes('events')) {
|
||||
const subscriptions: Array<{ event: string; callback: (data: unknown) => void }> = [];
|
||||
|
||||
api.events = {
|
||||
on(event: string, callback: (data: unknown) => void) {
|
||||
if (typeof event !== 'string' || typeof callback !== 'function') {
|
||||
throw new Error('event.on requires (string, function)');
|
||||
}
|
||||
pluginEventBus.on(event, callback);
|
||||
subscriptions.push({ event, callback });
|
||||
},
|
||||
off(event: string, callback: (data: unknown) => void) {
|
||||
pluginEventBus.off(event, callback);
|
||||
const idx = subscriptions.findIndex(s => s.event === event && s.callback === callback);
|
||||
if (idx >= 0) subscriptions.splice(idx, 1);
|
||||
},
|
||||
};
|
||||
|
||||
// Return a cleanup function that removes all subscriptions
|
||||
const originalCleanup = () => {
|
||||
for (const sub of subscriptions) {
|
||||
pluginEventBus.off(sub.event, sub.callback);
|
||||
}
|
||||
subscriptions.length = 0;
|
||||
};
|
||||
// Attach to meta for later use
|
||||
(api as { _eventCleanup?: () => void })._eventCleanup = originalCleanup;
|
||||
}
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and execute a plugin in a sandboxed context.
|
||||
*/
|
||||
export async function loadPlugin(
|
||||
meta: PluginMeta,
|
||||
groupId: GroupId,
|
||||
agentId: AgentId,
|
||||
): Promise<void> {
|
||||
if (loadedPlugins.has(meta.id)) {
|
||||
console.warn(`Plugin '${meta.id}' is already loaded`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the plugin's entry file
|
||||
let code: string;
|
||||
try {
|
||||
code = await readPluginFile(meta.id, meta.main);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to read plugin '${meta.id}' entry file '${meta.main}': ${e}`);
|
||||
}
|
||||
|
||||
const api = buildPluginAPI(meta, groupId, agentId);
|
||||
|
||||
// Execute the plugin code in a sandbox via new Function().
|
||||
// The plugin receives `bterminal` as its only external reference.
|
||||
// No access to window, document, fetch, globalThis, etc.
|
||||
try {
|
||||
const sandbox = new Function(
|
||||
'bterminal',
|
||||
// Explicitly shadow dangerous globals.
|
||||
// `var` declarations in strict mode shadow the outer scope names,
|
||||
// making direct references resolve to `undefined`.
|
||||
// See file-level JSDoc for known limitations of this approach.
|
||||
`"use strict";
|
||||
var window = undefined;
|
||||
var document = undefined;
|
||||
var fetch = undefined;
|
||||
var globalThis = undefined;
|
||||
var self = undefined;
|
||||
var XMLHttpRequest = undefined;
|
||||
var WebSocket = undefined;
|
||||
var Function = undefined;
|
||||
var importScripts = undefined;
|
||||
var require = undefined;
|
||||
var process = undefined;
|
||||
var Deno = undefined;
|
||||
var __TAURI__ = undefined;
|
||||
var __TAURI_INTERNALS__ = undefined;
|
||||
${code}`,
|
||||
);
|
||||
// Bind `this` to undefined so plugin code cannot use `this` to reach
|
||||
// the global scope. In strict mode, `this` remains undefined.
|
||||
sandbox.call(undefined, Object.freeze(api));
|
||||
} catch (e) {
|
||||
// Clean up any partially registered commands
|
||||
removePluginCommands(meta.id);
|
||||
throw new Error(`Plugin '${meta.id}' execution failed: ${e}`);
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
removePluginCommands(meta.id);
|
||||
const eventCleanup = (api as { _eventCleanup?: () => void })._eventCleanup;
|
||||
if (eventCleanup) eventCleanup();
|
||||
};
|
||||
|
||||
loadedPlugins.set(meta.id, { meta, cleanup });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload a plugin, removing all its registered commands and event subscriptions.
|
||||
*/
|
||||
export function unloadPlugin(id: string): void {
|
||||
const plugin = loadedPlugins.get(id);
|
||||
if (!plugin) return;
|
||||
plugin.cleanup();
|
||||
loadedPlugins.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all currently loaded plugins.
|
||||
*/
|
||||
export function getLoadedPlugins(): PluginMeta[] {
|
||||
return Array.from(loadedPlugins.values()).map(p => p.meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload all plugins.
|
||||
*/
|
||||
export function unloadAllPlugins(): void {
|
||||
for (const [id] of loadedPlugins) {
|
||||
unloadPlugin(id);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue