Unloading or replacing a plugin while its code was still being
fetched could leave a late instance running, or block reopening a
plugin that closed during startup.
Each load now carries an AbortController. Unloading, a newer load
of the same plugin or logout aborts it, and the plugin manager
closes itself on abort, also while it fetches its code.
createPlugin returns nothing for a plugin closed before its
sandbox exists, and failed loads clear their pending state so the
plugin can be loaded again.
Global plugin lifecycle:
- Add optional manifest scope "global" for plugins that remain active
across dashboard and workspace navigation within one browser tab.
Existing plugins retain workspace scope by default.
- Preserve global sandboxes when a workspace ends or another plugin
loads, including when that other plugin fails to initialize.
- Avoid duplicate instances of an already-running global plugin.
- Close global plugins on explicit closure or logout, with idempotent
cleanup of listeners, timers and UI resources. Global scope does not
imply automatic startup or persistence across a browser reload.
Plugin API and navigation:
- Keep a permanently live penpot facade whose getters follow the current
workspace, while user, theme and UI facilities remain available on
the dashboard.
- Expose penpotMgmt to global plugins with workspace metadata,
none/loading/ready status and workspacechange subscriptions.
- Add awaitable openFile(fileId, {teamId}) navigation in the current tab,
defaulting to the current team and resolving only once the requested
file and its active page are ready.
- Reject invalid identifiers, failed or superseded navigation and waits
exceeding 30 seconds; release navigation watchers on completion.
- Guard workspace-bound operations while no workspace is ready and
return null from root, currentFile and currentPage in that state.
- Require content:read for the initial management API. Callers must
reacquire file, page and shape references after changing files.
MCP lifecycle and connection controls:
- Make MCP a global plugin and initialize its integrated controller per
authenticated application session instead of per workspace.
- Load the enabled integration with a valid token on the dashboard as
well as in a file, retaining its existing hidden-iframe implementation.
- Keep enabling and connecting separate: an enabled plugin starts idle,
and each tab connects explicitly through dashboard or workspace
controls.
- Preserve the iframe, WebSocket, heartbeat and reconnect machinery
during navigation; disconnect on request and close on disable/logout.
MCP sessions and execution:
- Derive the short MCP session ID solely from currentUser.sessionId,
excluding the file ID. Retain the SHA-256-based, 50-bit Base32 encoding
to produce a stable, copyable 10-character identifier.
- Allow sessions without an open file and update workspace metadata over
the existing connection without replacing its routing identity.
- Refresh context after the connection handshake so initialization does
not leave stale file metadata.
- Expose penpotMgmt to execute_code, preserve execution storage across
navigation and skip layout waits when no workspace is ready.
- Update API types, tool guidance, documentation and the changelog.
Validation:
- Cover authenticated startup, workspace survival, logout cleanup,
readiness, navigation failure, duplicate loads and session routing.
- Verify that short IDs remain stable across files and reconnects and
that disconnect cancels pending asynchronous ID initialization.
- Pass 16 focused frontend tests, 73 runtime tests, 30 MCP plugin tests,
builds, TypeScript checks and frontend/runtime lint.
- Pass all 73 MCP server tests with --test-force-exit; the normal runner
still stays alive after assertions finish.
- Verify a live dashboard-to-file-to-dashboard round trip without a
document or iframe reload, preserving facade identity and storage.
AI-assisted-by: gpt-6
* 🐛 Validate plugin UI URLs cannot target Penpot's own domain
The plugin UI iframe combines allow-scripts and allow-same-origin in
its sandbox. While necessary for plugins to use their own cookies and
storage, this creates a risk if a plugin's UI URL resolves to Penpot's
own origin, potentially allowing the iframe to escape sandbox isolation.
Add validateUIUrl() that checks the resolved URL against Penpot's
origin (from penpotPublicURI or location.origin) and throws if they
match. Called in openModal() after prepareUrl() resolves the URL.
Closes#11271
AI-assisted-by: qwen3.7-plus
* 💄 Fix prettier formatting in plugin-manager.spec.ts
Apply prettier formatting to fix format:check failure.
AI-assisted-by: qwen3.7-plus
* 🐛 Fix problem with penpot origin plugins
---------
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
* 🐛 Fix plugin postMessage channel allowing cross-plugin message injection
The global postMessage listener was broadcasting incoming messages to all
loaded plugins without validating the origin or routing to the correct
sender. This allowed any plugin (or any iframe from any origin) to inject
messages into other plugins.
- Added origin validation — messages from origins other than
window.location.origin are rejected.
- Added sender-based routing — a message is only delivered to the plugin
whose iframe contentWindow matches event.source.
- Exposed iframeWindow getters in PluginManager, PluginModalElement, and
createPlugin so the runtime can compare event.source against the correct
iframe reference.
- Updated documentation examples to include origin validation and
recommend window.location.origin over '*' for postMessage targetOrigin.
AI-assisted-by: qwen3.7-plus
* 🐛 Fix plugin origin check breaking cross-origin plugin messaging
The origin check added in the previous commit compared event.origin
against window.location.origin (Penpot own origin). Since plugins
are cross-origin by design (hosted on the plugin author domain),
this check rejected every legitimate message from every real plugin.
The event.source-based sender routing (matching iframeWindow identity)
is the correct and sufficient security mechanism - it cannot be forged
cross-origin, so the redundant origin check was removed.
- Removed event.origin check from load-plugin.ts message listener
- Updated tests to use realistic plugin origins (localhost:4202/4203)
and to verify rejection based on source identity, not origin
- Fixed documentation examples: use event.source for receiving
validation and '*' for postMessage targetOrigin
AI-assisted-by: mimo-v2.5-pro
* 🐛 Handle plugin errors gracefully without crashing the UI
Plugin errors (like 'Set is not a constructor') were propagating to the
global error handler and showing the exception page. This fix:
- Uses a WeakMap to track plugin errors (works in SES hardened environment)
- Wraps setTimeout/setInterval handlers to mark errors and re-throw them
- Frontend global handler checks isPluginError and logs to console
Plugin errors are now logged to console with 'Plugin Error' prefix but
don't crash the main application or show the exception page.
Signed-off-by: AI Agent <agent@penpot.app>
* ✨ Improved handling of plugin errors on initialization
* ✨ Fix test and linter
---------
Signed-off-by: AI Agent <agent@penpot.app>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>