🎉 Introduce global plugins and move MCP to application scope

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
This commit is contained in:
Dominik Jain 2026-10-02 12:20:51 +00:00 committed by alonso.torres
parent 31000c3055
commit be17fabc6a
37 changed files with 647 additions and 94 deletions

View File

@ -58,6 +58,7 @@
[:map
[:plugin-id :string]
[:version {:optional true} :int]
[:scope {:optional true} [:enum "workspace" "global"]]
[:name [:string {:max (:name registry-entry-max-lengths)}]]
[:description {:optional true} [:string {:max (:description registry-entry-max-lengths)}]]
[:host [:string {:max (:host registry-entry-max-lengths)}]]

View File

@ -74,16 +74,17 @@ There are three key pieces:
Some important concepts for users:
* **Integrations page**: MCP is configured under **Your account → Integrations → MCP Server**. Here you enable or disable MCP, get the server URL and manage the MCP key.
* **MCP key**: a personal, non-recoverable token that authenticates your AI client with the MCP server. Only one key can exist per user at a time. This is used by the remote MCP setup.
* **MCP session**: a connection from a Penpot tab to the MCP server, identified by a short session ID. Several tabs can connect independently, including tabs showing the same file.
* **MCP session**: a connection from a Penpot tab to the MCP server, identified by a short session ID derived from the tab's user session. Several tabs can connect independently, including tabs showing the same file.
* **Current page**: the page open in the selected session. Switching browser focus to another tab does not change which session the agent uses.
### Working with multiple files
Connect the tabs you want to use:
* **Remote MCP**: open each file and choose **MCP → Connect** in the toolbar. Use **Disconnect** in the same menu to disconnect that tab.
* **Remote MCP**: choose **MCP: Connect** in the dashboard sidebar or **MCP → Connect** in a file's toolbar. Use **Disconnect** in the same place to disconnect that tab.
* **Local MCP**: run the plugin in each file and connect it to your local server. Each plugin connection has its own session ID.
* Connecting or disconnecting one tab does not disconnect the others.
* A connected tab stays connected while navigating between the dashboard and files. Its session ID stays the same; requests operate on the currently open file.
Choose a session for your agent:
@ -176,7 +177,7 @@ When this happens, MCP fails fast instead of waiting for a long task timeout:
To recover:
* If the tab was only suspended, focus it, wait until MCP reconnects, and retry the prompt.
* If the tab was unloaded or reloaded, open the file and connect again. Copy the new session ID if your agent was using the previous one.
* If the tab was unloaded or reloaded, connect again from the dashboard or a file. Copy the new session ID if your agent was using the previous one.
To reduce the chances of the browser putting Penpot to sleep during long MCP sessions:
@ -271,7 +272,7 @@ Remote MCP is the easiest way to start using AI agents with Penpot. It's hosted
1. Open **Your account → Integrations**.
2. In the **MCP Server** section, read the short description to confirm that feature is available for your account.
3. Use the **Status** toggle to enable MCP Server. Penpot remembers this state per user across sessions. You still need to connect each file explicitly.
3. Use the **Status** toggle to enable MCP Server. Penpot remembers this state per user across sessions. You still need to connect each tab explicitly.
4. If this is your first time, Penpot will ask you to **generate an MCP key**. The key is shown only once, store it safely.
* Treat the MCP key like a password/token: do not share it in screenshots, logs, or code samples.
5. Once enabled, you will see:

View File

@ -16,6 +16,7 @@
[app.main.data.event :as ev]
[app.main.data.profile :as dp]
[app.main.data.websocket :as ws]
[app.main.data.workspace.mcp :as mcp]
[app.main.errors]
[app.main.features :as feat]
[app.main.rasterizer :as thr]
@ -81,6 +82,7 @@
(watch [_ _ stream]
(rx/merge
(rx/of (ev/initialize)
(mcp/initialize)
(dp/refresh-profile))
;; Watch for profile deletion events

View File

@ -58,7 +58,7 @@
(update-in state [:workspace-local :open-plugins] (fnil disj #{}) id))))
(defn start-plugin!
[{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions]
[{:keys [plugin-id name version description host code permissions allow-background scope]} ^js extensions]
(let [load-plugin (unchecked-get ug/global "ɵloadPlugin")]
(if (fn? load-plugin)
(-> (load-plugin
@ -69,6 +69,7 @@
:host host
:code code
:allowBackground (boolean allow-background)
:scope scope
:permissions (apply array permissions)}
nil
extensions)
@ -82,7 +83,7 @@
:action "start-plugin!"))))
(defn- load-plugin!
[{:keys [plugin-id name version description host code icon permissions]}]
[{:keys [plugin-id name version description host code icon permissions scope]}]
(st/emit! (pflag/initialize plugin-id version)
(save-current-plugin plugin-id))
@ -96,6 +97,7 @@
:host host
:code code
:icon icon
:scope scope
:permissions (apply array permissions)}
(fn []
(st/emit! (remove-current-plugin plugin-id))))

View File

@ -55,7 +55,6 @@
[app.main.data.workspace.layers :as dwly]
[app.main.data.workspace.layout :as layout]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.mcp :as mcp]
[app.main.data.workspace.notifications :as dwn]
[app.main.data.workspace.pages :as dwpg]
[app.main.data.workspace.path :as dwdp]
@ -77,7 +76,6 @@
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.router :as rt]
[app.plugins.register :as preg]
[app.render-wasm :as wasm]
[app.render-wasm.api :as wasm.api]
[app.util.dom :as dom]
@ -259,15 +257,8 @@
ptk/WatchEvent
(watch [_ _ _]
(rx/merge
(rx/of (dp/check-open-plugin)
(fdf/fix-deleted-fonts-for-local-library file-id))
(if (contains? cf/flags :mcp)
;; We wait the plugin runtime to be ready before launch the
;; mcp initialization
(->> (rx/from (preg/wait-for-runtime))
(rx/map (fn [_] (mcp/init))))
(rx/empty))))))
(rx/of (dp/check-open-plugin)
(fdf/fix-deleted-fonts-for-local-library file-id)))))
(defn- compute-shape-stats
"Compute shape statistics in a single pass over pages-index.

View File

@ -12,13 +12,14 @@
[app.common.uri :as u]
[app.config :as cf]
[app.main.broadcast :as mbc]
[app.main.data.auth :as-alias auth]
[app.main.data.plugins :as dp]
[app.main.data.profile :as du]
[app.main.data.workspace :as-alias dw]
[app.main.store :as st]
[app.plugins.register :as preg]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
[beicon.v2.operators :as rxo]
[potok.v2.core :as ptk]))
(def reconnect-fallback-interval 60000)
@ -36,6 +37,7 @@
:plugin-id preg/mcp-plugin-id
:description "This plugin enables interaction with the Penpot MCP server"
:allow-background true
:scope "global"
:permissions
#{"user:read"
"library:read" "library:write"
@ -147,7 +149,7 @@
(assoc :host (str (u/join cf/public-uri "plugins/mcp/"))))
stopper-s (rx/merge
(rx/filter (ptk/type? ::dw/finalize-workspace) stream)
(rx/filter (ptk/type? ::auth/logged-out) stream)
(rx/filter (ptk/type? ::stop-mcp-plugin) stream)
(rx/filter (ptk/type? ::init) stream))
@ -234,7 +236,7 @@
ptk/WatchEvent
(watch [_ state stream]
(let [stopper-s (rx/merge
(rx/filter (ptk/type? ::dw/finalize-workspace) stream)
(rx/filter (ptk/type? ::auth/logged-out) stream)
(rx/filter (ptk/type? ::init) stream))
mcp-state (get state :mcp)]
@ -274,3 +276,25 @@
(stop-mcp-plugin))))))
(rx/take-until stopper-s))))))
(defn initialize
"Initialize the MCP controller for each authenticated app session."
[]
(ptk/reify ::initialize
ptk/WatchEvent
(watch [_ _ stream]
(if (contains? cf/flags :mcp)
(->> stream
(rx/filter #(or (ptk/type? ::du/set-profile %)
(ptk/type? ::auth/logged-out %)))
(rx/map #(when (ptk/type? ::du/set-profile %)
(:id (deref %))))
(rx/pipe (rxo/distinct-contiguous))
(rx/switch-map
(fn [profile-id]
(if profile-id
(->> (rx/from (preg/wait-for-runtime))
(rx/map (fn [_] (init))))
(rx/empty))))
(rx/take-until (rx/filter (ptk/type? ::initialize) stream)))
(rx/empty)))))

View File

@ -17,6 +17,7 @@
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.team :as dtm]
[app.main.data.workspace.mcp :as mcp]
[app.main.refs :as refs]
[app.main.router :as rt]
[app.main.store :as st]
@ -255,6 +256,13 @@
(get default-project :id)
team-id (get team :id)
mcp-state (mf/deref refs/mcp)
mcp-requested? (:connection-requested mcp-state)
toggle-mcp (mf/use-fn
(mf/deps mcp-requested?)
#(st/emit! (if mcp-requested?
(mcp/user-disconnect-mcp)
(mcp/connect-mcp))))
projects? (= section :dashboard-recent)
fonts? (= section :dashboard-fonts)
@ -382,6 +390,21 @@
[:span {:class (stl/css :element-title)} (tr "labels.shared-libraries")]]]]]
(when (and (contains? cf/flags :mcp)
(:enabled mcp-state)
(:token-valid mcp-state))
[:div {:class (stl/css :sidebar-content-section)}
[:ul {:class (stl/css :sidebar-nav)}
[:li {:class (stl/css :sidebar-nav-item)}
[:> link* {:action toggle-mcp
:keyboard-action toggle-mcp
:class (stl/css :sidebar-link)}
[:span {:class (stl/css :element-title)}
"MCP: "
(if mcp-requested?
(tr "workspace.header.menu.mcp.plugin.status.disconnect")
(tr "workspace.header.menu.mcp.plugin.status.connect"))]]]]])
[:div {:class (stl/css :sidebar-content-section)
:data-testid "pinned-projects"}
[:div {:class (stl/css :sidebar-section-title)}

View File

@ -43,6 +43,7 @@
[app.plugins.history :as history]
[app.plugins.library :as library]
[app.plugins.local-storage :as local-storage]
[app.plugins.management :as management]
[app.plugins.page :as page]
[app.plugins.parser :as parser]
[app.plugins.reflow :as wrfp]
@ -88,6 +89,9 @@
:$plugin {:enumerable false :get (fn [] plugin-id)}
;; Public properties
:management
{:get (fn [] (management/create-context))}
:version
{:this true
:get (constantly (:base cf/version))}

View File

@ -10,6 +10,7 @@
[app.main.data.helpers :as dsh]
[app.main.store :as st]
[app.plugins.file :as file]
[app.plugins.management :as management]
[app.plugins.page :as page]
[app.plugins.parser :as parser]
[app.plugins.shape :as shape]
@ -19,6 +20,21 @@
(defmulti handle-state-change (fn [type _ _ _ _] type))
(defmethod handle-state-change "workspacechange"
[_ _ old-val new-val _]
(let [old-workspace (management/workspace-context old-val)
new-workspace (management/workspace-context new-val)]
(if (= old-workspace new-workspace)
::not-changed
(clj->js new-workspace))))
(defmethod handle-state-change "logout"
[_ _ old-val new-val _]
(if (and (:profile-id old-val)
(not= (:profile-id old-val) (:profile-id new-val)))
::void
::not-changed))
(defmethod handle-state-change "finish"
[_ _ old-val new-val _]
(let [old-file-id (:current-file-id old-val)
@ -123,7 +139,9 @@
(try
(let [result (handle-state-change type plugin-id old-val new-val props)]
(when (not= ::not-changed result)
(debounced-callback result)))
(if (contains? #{"workspacechange" "logout"} type)
(safe-callback result)
(debounced-callback result))))
(catch :default cause
(.error js/console cause)))))

View File

@ -0,0 +1,78 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.plugins.management
(:require
[app.common.uuid :as uuid]
[app.main.data.helpers :as dsh]
[app.main.router :as rt]
[app.main.store :as st]
[app.util.object :as obj]))
(defn workspace-context
[state]
(let [params (rt/get-params state)
file-id (when (= :workspace (rt/lookup-name state))
(uuid/parse* (:file-id params)))
ready? (and file-id
(nil? (:exception state))
(= file-id (:current-file-id state) (:workspace-ready state))
(some? (:current-page-id state))
(some? (dsh/lookup-page state file-id (:current-page-id state))))]
{:status (cond ready? "ready" file-id "loading" :else "none")
:fileId (some-> file-id str)
:fileName (when file-id (:name (dsh/lookup-file state file-id)))
:teamId (some-> (:current-team-id state) str)}))
(defn open-file
[file-id options]
(let [file-id (uuid/parse* file-id)
team-id (if-let [id (obj/get options "teamId")]
(uuid/parse* id)
(:current-team-id @st/state))]
(cond
(or (nil? file-id) (nil? team-id))
(js/Promise.reject (js/Error. "Expected a file UUID and a team UUID"))
(and (= "ready" (:status (workspace-context @st/state)))
(= file-id (:current-file-id @st/state))
(= team-id (:current-team-id @st/state)))
(js/Promise.resolve nil)
:else
(js/Promise.
(fn [resolve reject]
(let [key (js/Symbol)
initial-route (:route @st/state)
timer (atom nil)
finish (fn [error]
(remove-watch st/state key)
(js/clearTimeout @timer)
(if error (reject error) (resolve nil)))]
(reset! timer (js/setTimeout #(finish (js/Error. "Timed out opening file")) 30000))
(add-watch
st/state key
(fn [_ _ _ state]
(let [workspace (workspace-context state)
target? (and (= (str file-id) (:fileId workspace))
(= (str team-id) (:team-id (rt/get-params state))))]
(cond
(:exception state)
(finish (js/Error. "Unable to open file"))
(and target? (= "ready" (:status workspace)))
(finish nil)
(and (not= initial-route (:route state)) (not target?))
(finish (js/Error. "File navigation was superseded"))))))
(st/emit! (rt/nav :workspace {:team-id team-id :file-id file-id}))))))))
(defn create-context
[]
(obj/reify {:name "PenpotManagementContext"}
:workspace
{:get (fn [] (clj->js (workspace-context @st/state)))}
:openFile open-file))

View File

@ -102,6 +102,7 @@
{:plugin-id plugin-id
:url (str plugin-url)
:version vers
:scope (obj/get manifest "scope")
:name name
:description desc
:host origin

View File

@ -8,14 +8,43 @@
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.plugins :as dp]
[app.main.data.profile :as du]
[app.main.data.workspace.mcp :as mcp]
[app.plugins.register :as preg]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as a]
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
(t/deftest ^:async test-initialize-once-per-authenticated-session
(let [stream (rx/subject)
events (atom [])
profile {:id (uuid/next)}]
(await
(mock/with-mocks*
{cf/flags #{:mcp}
preg/wait-for-runtime (fn [] (js/Promise.resolve true))}
(let [subscription (rx/subs! #(swap! events conj (ptk/type %))
(ptk/watch (mcp/initialize) {} stream))]
(try
(rx/push! stream (du/set-profile profile))
(await (a/wait-for #(= 1 (count @events))))
(rx/push! stream (du/set-profile profile))
(rx/push! stream (ptk/data-event :app.main.data.workspace/finalize-workspace))
(await (a/settle))
(t/is (= [:app.main.data.workspace.mcp/init] @events))
(rx/push! stream (ptk/data-event :app.main.data.auth/logged-out))
(rx/push! stream (du/set-profile profile))
(await (a/wait-for #(= 2 (count @events))))
(t/is (= [:app.main.data.workspace.mcp/init
:app.main.data.workspace.mcp/init] @events))
(finally
(rx/dispose! subscription)
(rx/end! stream))))))))
(t/deftest test-update-mcp-status
(t/testing "enables MCP in profile props and mcp state"
(let [state {:profile {:props {:mcp-enabled false}} :mcp {}}
@ -151,7 +180,7 @@
(t/is (= "disconnected" (get-in result [:mcp :connection-status])))
(t/is (nil? (get-in result [:mcp :session-id])))))
(t/deftest test-init-clears-previous-file-connection
(t/deftest test-init-clears-previous-app-connection
(let [state {:profile {:props {:mcp-enabled true}}
:mcp {:connection-requested true
:connection-status "connected"
@ -165,8 +194,8 @@
(let [state {:mcp {:connection-requested false :connection-status "disconnected"}}]
(t/is (= state (ptk/update (mcp/update-mcp-connection-status "connected") state)))))
(t/deftest test-plugin-callbacks-stop-with-workspace
(doseq [stop-event [:app.main.data.workspace/finalize-workspace
(t/deftest test-plugin-callbacks-stop-with-app-session
(doseq [stop-event [:app.main.data.auth/logged-out
:app.main.data.workspace.mcp/init]]
(let [stream (rx/subject)
extension (atom nil)
@ -179,6 +208,8 @@
(.on @extension "connect" #(swap! calls inc))
(rx/push! stream (ptk/data-event :app.main.data.workspace.mcp/connect))
(t/is (= 1 @calls))
(rx/push! stream (ptk/data-event :app.main.data.workspace/finalize-workspace))
(t/is (zero? @closed))
(rx/push! stream (ptk/data-event stop-event))
(t/is (= 1 @closed))
(t/is (false? (.isConnectionRequested @extension)))

View File

@ -0,0 +1,83 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.plugins.management-test
(:require
[app.common.uuid :as uuid]
[app.main.store :as st]
[app.plugins.events :as events]
[app.plugins.management :as management]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as a]
[frontend-tests.helpers.mock :as mock]))
(def file-id (uuid/next))
(def page-id (uuid/next))
(def team-id (uuid/next))
(def dashboard
{:route {:data {:name :dashboard-recent}}
:current-team-id team-id})
(def loading
(assoc dashboard
:route {:data {:name :workspace}
:params {:query {:file-id (str file-id) :team-id (str team-id)}}}
:current-file-id file-id
:workspace-ready file-id))
(def ready
(assoc loading
:current-page-id page-id
:files {file-id {:id file-id :name "Design"
:data {:pages-index {page-id {:id page-id :objects {}}}}}}))
(t/deftest workspace-readiness-requires-an-initialized-page
(t/is (= "none" (:status (management/workspace-context dashboard))))
(t/is (= "loading" (:status (management/workspace-context loading))))
(t/is (= "ready" (:status (management/workspace-context ready))))
(t/is (= "none" (:status (management/workspace-context (assoc ready :route (:route dashboard)))))))
(t/deftest ^:async open-file-waits-for-page-readiness
(let [state (atom dashboard)
seen (atom [])
done? (atom false)]
(await
(mock/with-mocks*
{st/state state
st/emit! (fn ([_] (js/setTimeout #(reset! state loading) 0))
([_ & _] nil))}
(let [listener (events/add-listener "workspacechange" (str uuid/zero)
#(swap! seen conj (.-status %)) nil)
result (-> (management/open-file (str file-id) nil)
(.then #(reset! done? true)))]
(try
(await (a/wait-for #(= ["loading"] @seen)))
(t/is (false? @done?))
(reset! state ready)
(await result)
(t/is (= ["loading" "ready"] @seen))
(t/is (true? @done?))
(finally
(events/remove-listener listener))))))))
(t/deftest ^:async open-file-rejects-superseded-navigation
(let [state (atom dashboard)]
(await
(mock/with-mocks*
{st/state state
st/emit! (fn ([_] (js/setTimeout #(reset! state loading) 0))
([_ & _] nil))}
(let [result (-> (management/open-file (str file-id) nil)
(.then (fn [] nil) (fn [error] (.-message error))))]
(await (a/wait-for #(= loading @state)))
(reset! state {:route {:data {:name :settings-profile}}})
(t/is (= "File navigation was superseded" (await result))))))))
(t/deftest ^:async open-file-rejects-invalid-identifiers
(let [result (-> (management/open-file "invalid" #js {:teamId (str team-id)})
(.then (fn [] nil) (fn [error] (.-message error))))]
(t/is (= "Expected a file UUID and a team UUID" (await result)))))

View File

@ -73,6 +73,7 @@
[frontend-tests.plugins.interactions-test]
[frontend-tests.plugins.library-test]
[frontend-tests.plugins.local-storage-test]
[frontend-tests.plugins.management-test]
[frontend-tests.plugins.page-active-validation-test]
[frontend-tests.plugins.page-test]
[frontend-tests.plugins.parser-test]
@ -214,6 +215,7 @@
'frontend-tests.plugins.comments-test
'frontend-tests.plugins.context-shapes-test
'frontend-tests.plugins.events-test
'frontend-tests.plugins.management-test
'frontend-tests.plugins.file-test
'frontend-tests.plugins.flex-test
'frontend-tests.plugins.format-test

View File

@ -33,6 +33,16 @@ This repository thus contains not only the MCP server implementation itself
but also the supporting Penpot MCP Plugin
(see section [Repository Structure](#repository-structure) below).
## Global plugin lifecycle
- The MCP plugin stays loaded across dashboard and workspace navigation, including when the integration runs with its UI hidden.
- Enabling the integration loads the plugin; connecting remains an explicit action in the current tab. Logout closes it.
- The MCP session ID is a short, 10-character Base32 hash of `penpot.currentUser.sessionId` alone. File metadata changes without replacing the WebSocket connection or execution `storage`.
- `penpotMgmt.workspace` reports `none`, `loading` or `ready`, with file and team metadata. Subscribe through `penpotMgmt.on("workspacechange", callback)`.
- `await penpotMgmt.openFile(fileId, {teamId})` opens a file in the same tab and waits for its page to be ready. The team defaults to the current team; failed navigation rejects the promise.
- The `penpot` facade stays live. File operations require a ready workspace; reacquire file, page and shape references after navigation.
- Global plugins declare `"scope": "global"` in their manifest. Management metadata and navigation currently require `content:read` permission.
## Demonstration
[![Video](https://v32155.1blu.de/penpot/PenpotFest2025_thumbnail.png)](https://v32155.1blu.de/penpot/PenpotFest2025.mp4)
@ -243,7 +253,7 @@ of the prompt input area.
Connect the files you want to use:
* Open each file in a separate Penpot tab, run the plugin, and connect it to the same MCP server.
* Each connection has its own short session ID, shown in the plugin UI with a **Copy** button.
* Each browser tab uses a short session ID derived only from `penpot.currentUser.sessionId`, shown in the plugin UI with a **Copy** button. The ID stays the same across workspace navigation.
* Multiple tabs can connect to the same file. Connecting or disconnecting one tab does not disconnect the others.
Choose a session for your agent:

View File

@ -1,8 +1,9 @@
/** Metadata identifying one connected Penpot plugin session. */
export interface PenpotSession {
sessionId: string;
fileId: string;
fileName: string;
fileId: string | null;
fileName: string | null;
workspaceState?: "none" | "loading" | "ready";
}
/** First message sent by the plugin on each new WebSocket connection. */

View File

@ -3,6 +3,7 @@
"code": "plugin.js",
"icon": "icon.jpg",
"version": 2,
"scope": "global",
"description": "This plugin enables interaction with the Penpot MCP server",
"permissions": ["user:read", "content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"]
}

View File

@ -92,7 +92,7 @@ test("sends file metadata before marking a plugin connection ready", async () =>
};
await ui.message(initialization);
const registeredSession = ui.sockets[0].sent[0].session;
assert.equal(registeredSession.sessionId, "pq3gxqddgj");
assert.equal(registeredSession.sessionId, "ftdmhltgwk");
assert.notEqual(registeredSession.sessionId, sessionId);
assert.deepEqual(registeredSession, { ...initialization.session, sessionId: registeredSession.sessionId });
assert.equal(
@ -100,7 +100,10 @@ test("sends file metadata before marking a plugin connection ready", async () =>
false
);
ui.sockets[0].receive({ type: "initialized" });
assert.equal(ui.messages.at(-1).status, "connected");
assert.equal(
ui.messages.filter((message) => message.type === "update-connection-status").at(-1).status,
"connected"
);
});
test("reconnects with the same session ID and ignores metadata for the old socket", async () => {
@ -163,6 +166,12 @@ test("plugin reads the current file for each connection metadata request", () =>
runInNewContext(compile("plugin.ts"), {
exports: {},
penpot,
penpotMgmt: {
get workspace() {
return { status: "ready", fileId: penpot.currentFile.id, fileName: penpot.currentFile.name };
},
on() {},
},
mcp: undefined,
require: () => ({ ExecuteCodeTaskHandler: class {} }),
});
@ -173,17 +182,17 @@ test("plugin reads the current file for each connection metadata request", () =>
{
type: "initialize",
penpotUserSessionId: "tab-1",
session: { sessionId: "first", fileId: "file-1", fileName: "First design" },
session: { sessionId: "first", fileId: "file-1", fileName: "First design", workspaceState: "ready" },
},
{
type: "initialize",
penpotUserSessionId: "tab-1",
session: { sessionId: "second", fileId: "file-2", fileName: "Second design" },
session: { sessionId: "second", fileId: "file-2", fileName: "Second design", workspaceState: "ready" },
},
]);
});
test("integrated connections reuse the short ID across reconnects", async () => {
test("integrated connections reuse the short session ID across reconnects", async () => {
const ui = pluginUi();
ui.message({ type: "mcp-mode", integratedRemoteMcp: true });
const initialize = async (requestId: string) => {
@ -194,15 +203,18 @@ test("integrated connections reuse the short ID across reconnects", async () =>
});
};
await initialize(ui.connect());
assert.equal(ui.sockets[0].sent[0].session.sessionId, "pq3gxqddgj");
assert.equal(ui.sockets[0].sent[0].session.sessionId, "ftdmhltgwk");
ui.sockets[0].receive({ type: "initialized" });
assert.equal(ui.messages.at(-1).sessionId, "pq3gxqddgj");
assert.equal(
ui.messages.filter((message) => message.type === "update-connection-status").at(-1).sessionId,
"ftdmhltgwk"
);
ui.sockets[0].close();
[...ui.timers.values()][0]();
ui.sockets[1].open();
const requestId = ui.messages.filter((message) => message.type === "connection-metadata-request").at(-1).sessionId;
await initialize(requestId);
assert.equal(ui.sockets[1].sent[0].session.sessionId, "pq3gxqddgj");
assert.equal(ui.sockets[1].sent[0].session.sessionId, "ftdmhltgwk");
});
test("disconnect cancels metadata initialization and ignores a late acknowledgement", async () => {
@ -216,8 +228,9 @@ test("disconnect cancels metadata initialization and ignores a late acknowledgem
});
ui.message({ type: "stop-server" });
await pending;
ui.sockets[0].receive({ type: "initialized" });
assert.deepEqual(ui.sockets[0].sent, []);
ui.sockets[0].receive({ type: "initialized" });
assert.ok(!ui.messages.some((message) => message.status === "connected"));
assert.equal(ui.messages.at(-1).status, "disconnected");
assert.equal(ui.timers.size, 0);
});
@ -243,6 +256,7 @@ test("integrated plugin stays idle until explicitly connected, including while t
},
on() {},
},
penpotMgmt: { on() {} },
mcp: {
getToken: () => "test-token",
getServerUrl: () => "ws://localhost:4402",
@ -280,7 +294,10 @@ test("standalone session ID survives reconnects and plugin restarts in the same
});
assert.equal(ui.sockets[1].sent[0].session.sessionId, sessionId);
ui.sockets[1].receive({ type: "initialized" });
assert.equal(ui.messages.at(-1).sessionId, sessionId);
assert.equal(
ui.messages.filter((message) => message.type === "update-connection-status").at(-1).sessionId,
sessionId
);
const next = pluginUi();
const nextRequestId = next.connect();
@ -291,3 +308,37 @@ test("standalone session ID survives reconnects and plugin restarts in the same
});
assert.equal(next.sockets[0].sent[0].session.sessionId, sessionId);
});
test("file metadata does not affect the short session ID", async () => {
for (const fileId of [null, "file-1", "file-2"]) {
const ui = pluginUi();
const requestId = ui.connect();
await ui.message({
type: "initialize",
penpotUserSessionId: "tab-1",
session: { sessionId: requestId, fileId, fileName: fileId },
});
assert.equal(ui.sockets[0].sent[0].session.sessionId, "ftdmhltgwk");
}
});
test("dashboard sessions use the short session ID and publish context changes without reconnecting", async () => {
const ui = pluginUi();
const requestId = ui.connect();
await ui.message({
type: "initialize",
penpotUserSessionId: "tab-1",
session: { sessionId: requestId, fileId: null, fileName: null, workspaceState: "none" },
});
assert.equal(ui.sockets[0].sent[0].session.sessionId, "ftdmhltgwk");
ui.sockets[0].receive({ type: "initialized" });
assert.equal(ui.messages.at(-1).type, "context-request");
for (const context of [
{ fileId: "file-1", fileName: "Design", workspaceState: "ready" },
{ fileId: null, fileName: null, workspaceState: "none" },
]) {
await ui.message({ type: "context-update", context });
assert.deepEqual(ui.sockets[0].sent.at(-1), { type: "context-update", context });
}
assert.equal(ui.sockets.length, 1);
});

View File

@ -2,13 +2,13 @@ import assert from "node:assert/strict";
import test from "node:test";
import { SessionId } from "./SessionId.ts";
test("derives a stable 50-bit Base32 identifier from the Penpot user session and file", async () => {
assert.equal(await SessionId.forFile("tab-1", "file-1"), "pq3gxqddgj");
assert.equal(await SessionId.forFile("tab-1", "file-1"), "pq3gxqddgj");
assert.equal(await SessionId.forFile("tab-2", "file-1"), "jablgbun4p");
assert.equal(await SessionId.forFile("tab-1", "file-2"), "7s3kow4ucx");
test("derives a stable 50-bit Base32 identifier from only the Penpot user session", async () => {
assert.equal(await SessionId.forSession("tab-1"), "ftdmhltgwk");
assert.equal(await SessionId.forSession("tab-1"), "ftdmhltgwk");
assert.equal(await SessionId.forSession("tab-2"), "cb3oexpdmd");
});
test("encodes the Penpot user session and file without ambiguous boundaries", async () => {
assert.notEqual(await SessionId.forFile("ab", "c"), await SessionId.forFile("a", "bc"));
test("shortens a UUID session to ten copyable Base32 characters", async () => {
const sessionId = await SessionId.forSession("a7457b7c-cf45-80d4-8008-ba5b1d08e6c8");
assert.match(sessionId, /^[a-z2-7]{10}$/);
});

View File

@ -2,8 +2,8 @@
export class SessionId {
private static readonly ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
public static async forFile(penpotUserSessionId: string, fileId: string): Promise<string> {
const input = new TextEncoder().encode(JSON.stringify([penpotUserSessionId, fileId]));
public static async forSession(penpotUserSessionId: string): Promise<string> {
const input = new TextEncoder().encode(penpotUserSessionId);
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input));
return SessionId.encode(digest);
}

View File

@ -8,6 +8,7 @@ interface McpOptions {
declare global {
const mcp: undefined | McpOptions;
const penpotMgmt: import("../../../../plugins/libs/plugin-types").PenpotMgmt;
}
export {};

View File

@ -249,6 +249,7 @@ function connectToMcpServer(baseUrl?: string, token?: string): void {
cancelReconnect();
startHeartbeat();
updateConnectionStatus("connected", "Connected");
parent.postMessage({ type: "context-request" }, "*");
return;
}
// Track the current task received from the MCP server
@ -349,10 +350,7 @@ window.addEventListener("message", async (event) => {
if (!initialization.penpotUserSessionId) {
throw new Error("Missing Penpot user session ID");
}
const sessionId = await SessionId.forFile(
initialization.penpotUserSessionId,
initialization.session.fileId
);
const sessionId = await SessionId.forSession(initialization.penpotUserSessionId);
if (
!shouldReconnect ||
ws !== socket ||
@ -377,6 +375,12 @@ window.addEventListener("message", async (event) => {
}
return;
}
if (event.data.type === "context-update") {
if (connectionReady && ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(event.data));
}
return;
}
if (event.data.type === "mcp-mode") {
isIntegratedRemoteMcp = event.data.integratedRemoteMcp;
}

View File

@ -57,7 +57,7 @@ penpot.ui.onMessage<
penpotVersion: penpotVersionPrefix,
});
}
// connect only when requested in this workspace
// connect only when requested in this tab
if (isIntegratedRemoteMcp && mcp?.isConnectionRequested()) {
penpot.ui.sendMessage({
type: "start-server",
@ -66,15 +66,22 @@ penpot.ui.onMessage<
});
}
} else if (typeof message === "object" && message.type === "connection-metadata-request") {
const file = penpot.currentFile;
if (file && message.sessionId) {
const workspace = penpotMgmt.workspace;
if (message.sessionId) {
const initialization: PluginConnectionInit & { penpotUserSessionId?: string } = {
type: "initialize",
session: { sessionId: message.sessionId, fileId: file.id, fileName: file.name },
session: {
sessionId: message.sessionId,
fileId: workspace.fileId,
fileName: workspace.fileName,
workspaceState: workspace.status,
},
penpotUserSessionId: penpot.currentUser.sessionId,
};
penpot.ui.sendMessage(initialization);
}
} else if (typeof message === "object" && message.type === "context-request") {
sendWorkspaceContext();
} else if (typeof message === "object" && message.type === "update-connection-status") {
mcp?.setMcpStatus(message.status || "unknown", message.sessionId);
} else if (typeof message === "object" && message.task && message.id) {
@ -85,6 +92,21 @@ penpot.ui.onMessage<
}
});
/** Sends the current workspace metadata without replacing the connection. */
function sendWorkspaceContext(): void {
const workspace = penpotMgmt.workspace;
penpot.ui.sendMessage({
type: "context-update",
context: {
fileId: workspace.fileId,
fileName: workspace.fileName,
workspaceState: workspace.status,
},
});
}
penpotMgmt.on("workspacechange", sendWorkspaceContext);
/**
* Handles plugin task requests received from the MCP server via WebSocket.
*

View File

@ -178,6 +178,7 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
// initialize context, making penpot, penpotUtils, storage and the custom console available
this.context = {
penpot: penpot,
penpotMgmt: penpotMgmt,
storage: {},
console: new ExecuteCodeTaskConsole(),
penpotUtils: PenpotUtils,
@ -217,7 +218,7 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
// wait for layout updates prior to executing the supplied code (if method is available)
try {
// @ts-ignore - TODO Penpot.waitForLayoutUpdate is not yet in the released types
if (penpot.waitForLayoutUpdate) {
if (penpotMgmt.workspace.status === "ready" && penpot.waitForLayoutUpdate) {
// @ts-ignore
await penpot.waitForLayoutUpdate();
}

View File

@ -143,3 +143,29 @@ test("invalid initialization metadata cannot register a session", async () => {
assert.equal((await closed)[0], 1008);
await assert.rejects(bridge.executePluginTask(new PluginTask("test", {})), /No Penpot sessions/);
});
test("a dashboard session remains routable while its workspace metadata changes", async () => {
const socket = new WebSocket(`ws://127.0.0.1:${port}?userToken=alice`);
sockets.push(socket);
await once(socket, "open");
const initialized = once(socket, "message");
socket.send(
JSON.stringify({
type: "initialize",
session: { sessionId: "tab", fileId: null, fileName: null, workspaceState: "none" },
})
);
await initialized;
socket.on("message", (raw) => {
const request = JSON.parse(raw.toString());
socket.send(JSON.stringify({ id: request.id, success: true, data: "ok" }));
});
for (const context of [
{ fileId: "file-1", fileName: "Design", workspaceState: "ready" },
{ fileId: null, fileName: null, workspaceState: "none" },
]) {
socket.send(JSON.stringify({ type: "context-update", context }));
assert.equal((await bridge.executePluginTask(new PluginTask("test", {}), "tab")).data, "ok");
assert.deepEqual(bridge.getUserConnections("alice")!.getSessions(), [{ sessionId: "tab", ...context }]);
}
});

View File

@ -27,8 +27,9 @@ const connectionInitSchema = z.object({
type: z.literal("initialize"),
session: z.object({
sessionId: z.string().min(1).max(128),
fileId: z.string().min(1).max(128),
fileName: z.string(),
fileId: z.string().min(1).max(128).nullable(),
fileName: z.string().nullable(),
workspaceState: z.enum(["none", "loading", "ready"]).optional(),
}),
});
@ -170,6 +171,13 @@ export class PluginBridge implements TaskDispatchHost {
return;
}
connection.frozen = false;
if (message?.type === "context-update") {
const context = connectionInitSchema.shape.session
.omit({ sessionId: true })
.parse(message.context);
Object.assign(connection.session, context);
return;
}
if (message?.type === "heartbeat") {
return;
}

View File

@ -31,7 +31,7 @@ export abstract class TaskDispatcher {
sessions
.map(
(session) =>
`${session.sessionId}: ${JSON.stringify(session.fileName)} (file ${session.fileId})`
`${session.sessionId}: ${session.fileId ? `${JSON.stringify(session.fileName)} (file ${session.fileId})` : "no active workspace"}`
)
.join("\n")
);

View File

@ -49,8 +49,10 @@ export class ExecuteCodeTool extends Tool<ExecuteCodeArgs> {
"Executes JavaScript code in the Penpot plugin context.\n" +
"IMPORTANT: Before using this tool, make sure you have read the 'Penpot High-Level Overview' and know " +
"which Penpot API functionality is necessary and how to use it.\n" +
"You have access two main objects: `penpot` (the Penpot API, of type `Penpot`), `penpotUtils`, " +
"and `storage`.\n" +
"You have access to `penpot` (the Penpot API, of type `Penpot`), `penpotMgmt`, `penpotUtils`, and `storage`.\n" +
"`penpotMgmt.workspace.status` is none, loading or ready. Use `await penpotMgmt.openFile(fileId, {teamId})` " +
"to open a file and wait until it is ready; teamId defaults to the current team. " +
"File operations require a ready workspace. Storage survives navigation; reacquire file and shape objects after changing files.\n" +
"`storage` is an object in which arbitrary data can be stored, simply by adding a new attribute; " +
"stored attributes can be referenced in future calls to this tool, so any intermediate results that " +
"could come in handy later should be stored in `storage` instead of just a fleeting variable; " +

View File

@ -2,6 +2,8 @@
### 🚀 Features
- **plugins-runtime**: Added manifest `scope: "global"` for plugins that survive workspace navigation and close on logout.
- **plugin-types**: Added `penpotMgmt` with workspace readiness, `workspacechange` events and `openFile()`, available to global plugins with `content:read` permission.
- **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently.
- **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs.
- **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished

View File

@ -4,7 +4,7 @@
*/
export interface Penpot extends Omit<
Context,
'addListener' | 'removeListener'
'addListener' | 'removeListener' | 'management'
> {
readonly ui: {
/**
@ -778,10 +778,32 @@ export interface CommonLayout {
remove(): void;
}
/** Current workspace of this browser tab. */
export interface WorkspaceContext {
readonly status: 'none' | 'loading' | 'ready';
readonly fileId: string | null;
readonly fileName: string | null;
readonly teamId: string | null;
}
/** Application management API available to global plugins. */
export interface PenpotMgmt {
readonly workspace: WorkspaceContext;
/** Opens a file in this tab and resolves once its workspace and page are ready. */
openFile(fileId: string, options?: { teamId?: string }): Promise<void>;
/** Subscribes to workspace availability and metadata changes. */
on(
type: 'workspacechange',
callback: (workspace: WorkspaceContext) => void,
): symbol;
off(listenerId: symbol): void;
}
/**
* Represents the context of Penpot, providing access to various Penpot functionalities and data.
*/
export interface Context {
readonly management?: Pick<PenpotMgmt, 'workspace' | 'openFile'>;
/**
* Returns the current penpot version.
*/
@ -1556,6 +1578,8 @@ export interface Ellipse extends ShapeBase {
* ```
*/
export interface EventsMap {
readonly workspacechange: WorkspaceContext;
readonly logout: void;
/**
* The `pagechange` event is triggered when the active page in the project is changed.
*/
@ -5757,4 +5781,5 @@ export interface Viewport {
declare global {
const penpot: Penpot;
const penpotMgmt: PenpotMgmt;
}

View File

@ -19,6 +19,7 @@ import type {
Page,
Path,
Penpot,
PenpotMgmt,
Rectangle,
Shape,
SvgRaw,
@ -36,6 +37,8 @@ import { createPluginManager } from '../plugin-manager.js';
export const validEvents = [
'finish',
'logout',
'workspacechange',
'pagechange',
'filechange',
'selectionchange',
@ -47,10 +50,24 @@ export const validEvents = [
export function createApi(
plugin: Awaited<ReturnType<typeof createPluginManager>>,
) {
const checkPermission = (permission: Permissions) => {
const workspaceUnavailable = () =>
plugin.manifest.scope === 'global' &&
plugin.context.management?.workspace.status !== 'ready';
const requireWorkspace = () => {
if (workspaceUnavailable()) {
throw new Error(
'No workspace is ready. Use penpotMgmt.openFile() first.',
);
}
};
const checkPermission = (permission: Permissions, workspace = true) => {
if (!plugin.manifest.permissions.includes(permission)) {
throw new Error(`Permission ${permission} is not granted`);
}
if (workspace && /^(content|library|comment):/.test(permission))
requireWorkspace();
};
const penpot: Penpot = {
@ -162,7 +179,7 @@ export function createApi(
z.function().parse(callback);
// To suscribe to events needs the read permission
checkPermission('content:read');
checkPermission('content:read', false);
return plugin.registerListener(type, callback, props);
},
@ -178,18 +195,18 @@ export function createApi(
},
get root(): Shape | null {
checkPermission('content:read');
return plugin.context.root;
checkPermission('content:read', false);
return workspaceUnavailable() ? null : plugin.context.root;
},
get currentFile(): File | null {
checkPermission('content:read');
return plugin.context.currentFile;
checkPermission('content:read', false);
return workspaceUnavailable() ? null : plugin.context.currentFile;
},
get currentPage(): Page | null {
checkPermission('content:read');
return plugin.context.currentPage;
checkPermission('content:read', false);
return workspaceUnavailable() ? null : plugin.context.currentPage;
},
get selection(): Shape[] {
@ -203,10 +220,12 @@ export function createApi(
},
get viewport(): Viewport {
requireWorkspace();
return plugin.context.viewport;
},
get history(): HistoryContext {
requireWorkspace();
return plugin.context.history;
},
@ -395,7 +414,28 @@ export function createApi(
},
};
const penpotMgmt: PenpotMgmt = {
get workspace() {
checkPermission('content:read', false);
return plugin.context.management!.workspace;
},
openFile(fileId, options) {
checkPermission('content:read', false);
return plugin.context.management!.openFile(fileId, options);
},
on(type, callback) {
z.literal('workspacechange').parse(type);
z.function().parse(callback);
checkPermission('content:read', false);
return plugin.registerListener(type, callback);
},
off(listenerId) {
plugin.destroyListener(listenerId);
},
};
return {
penpot,
penpotMgmt,
};
}

View File

@ -33,6 +33,7 @@ describe('createSandbox', () => {
beforeEach(() => {
mockPlugin = {
manifest: { scope: 'workspace' },
code: 'console.log("Plugin running");',
timeouts: new Set<ReturnType<typeof setTimeout>>(),
} as unknown as Awaited<ReturnType<typeof createPluginManager>>;

View File

@ -1,4 +1,3 @@
import type { Penpot } from '@penpot/plugin-types';
import type { createPluginManager } from './plugin-manager';
import { createApi } from './api';
import { ses } from './ses.js';
@ -67,7 +66,7 @@ export function createSandbox(
const pluginApi = createApi(plugin);
const safeHandler = {
get(target: Penpot, prop: string, receiver: unknown) {
get(target: object, prop: string, receiver: unknown) {
const originalValue = Reflect.get(target, prop, receiver);
if (typeof originalValue === 'function') {
@ -202,6 +201,12 @@ export function createSandbox(
publicPluginApi = Object.assign(publicPluginApi, apiExtensions);
}
if (plugin.manifest.scope === 'global') {
Object.assign(publicPluginApi, {
penpotMgmt: new Proxy(pluginApi.penpotMgmt, safeHandler),
});
}
const compartment = ses.createCompartment(publicPluginApi);
return {

View File

@ -98,9 +98,14 @@ describe('loadPlugin real initialization path (regression for #11001)', () => {
);
// The plugin code really ran inside the sandbox: the manager registers
// `themechange` + `finish`, and the plugin code adds its own `finish`
// `themechange` + `finish` + `logout`, and the plugin code adds its own `finish`
// listener through the public API.
expect(fixture.listenerTypes).toEqual(['themechange', 'finish', 'finish']);
expect(fixture.listenerTypes).toEqual([
'themechange',
'finish',
'logout',
'finish',
]);
expect(getPlugins()).toHaveLength(1);
// The user-facing behavior from #11001: host-side augmentation of a
@ -112,7 +117,7 @@ describe('loadPlugin real initialization path (regression for #11001)', () => {
expect(() => {
freshWrapper.toString = () => 'patched-by-runtime';
}).not.toThrow();
expect(fixture.listenerTypes.length).toBe(3);
expect(fixture.listenerTypes.length).toBe(4);
});
it('denies the write API without permission and leaves the host untouched', async () => {
@ -166,4 +171,79 @@ describe('loadPlugin real initialization path (regression for #11001)', () => {
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
expect(lastCompartmentGlobalThis()['__selectionFrozen']).toBe(true);
});
it('keeps a global sandbox through navigation and other plugin loads, then closes it on logout', async () => {
const fixture = makeHostFixture();
let workspace = {
status: 'none',
fileId: null,
fileName: null,
teamId: null,
};
const listeners = new Map<
symbol,
{ type: string; callback: (value?: unknown) => void }
>();
const context = {
...fixture.context,
management: {
get workspace() {
return workspace;
},
openFile: async () => {},
},
addListener(type: string, callback: (value?: unknown) => void) {
const id = Symbol();
listeners.set(id, { type, callback });
return id;
},
removeListener(id: symbol) {
listeners.delete(id);
},
} as unknown as Context;
setContextBuilder(() => context);
const manifest = {
...makeManifest(
'globalThis.savedPenpot = penpot; globalThis.probe = penpotMgmt.workspace.status;',
['content:read', 'content:write'],
),
pluginId: 'global-plugin',
scope: 'global' as const,
};
await loadPlugin(manifest);
const globalPlugin = getPlugins().find(
(plugin) => plugin.manifest.pluginId === 'global-plugin',
)!;
const globals = globalPlugin.compartment.compartment.globalThis;
expect(globals['probe']).toBe('none');
expect(() =>
globalPlugin.compartment.compartment.evaluate('penpot.createRectangle()'),
).toThrow(/No workspace/);
for (const listener of [...listeners.values()]) {
if (listener.type === 'finish') listener.callback();
}
await loadPlugin(makeManifest('globalThis.workspacePlugin = true;', []));
expect(getPlugins()).toContain(globalPlugin);
await loadPlugin(manifest);
expect(
getPlugins().filter(
(plugin) => plugin.manifest.pluginId === 'global-plugin',
),
).toHaveLength(1);
workspace = { ...workspace, status: 'ready' };
globalPlugin.compartment.compartment.evaluate(
'penpot.createRectangle(); globalThis.sameFacade = savedPenpot === penpot;',
);
expect(globals['sameFacade']).toBe(true);
expect(fixture.createRectangle).toHaveBeenCalledOnce();
await expect(
loadPlugin(makeManifest('throw new Error("failure");', [])),
).rejects.toThrow('failure');
expect(getPlugins()).toContain(globalPlugin);
for (const listener of [...listeners.values()]) {
if (listener.type === 'logout') listener.callback();
}
expect(getPlugins()).toHaveLength(0);
expect(globals['penpotMgmt']).toBeUndefined();
expect(listeners.size).toBe(0);
});
});

View File

@ -17,20 +17,19 @@ export function setContextBuilder(builder: ContextBuilder) {
export const getPlugins = () => plugins;
const closeAllPlugins = () => {
// Background plugins keep running, so they must stay registered: the
// registry routes their UI messages and lets them be unloaded later.
const backgroundPlugins: typeof plugins = [];
plugins.forEach((pluginApi) => {
// Background and global plugins keep running, so they must stay registered:
// the registry routes their UI messages and lets them be unloaded later.
plugins = plugins.filter((pluginApi) => {
/* eslint-disable @typescript-eslint/no-explicit-any */
if ((pluginApi.manifest as any)?.allowBackground) {
backgroundPlugins.push(pluginApi);
} else {
pluginApi.plugin.close();
if (
pluginApi.manifest?.scope === 'global' ||
(pluginApi.manifest as any)?.allowBackground
) {
return true;
}
pluginApi.plugin.close();
return false;
});
plugins = backgroundPlugins;
};
window.addEventListener('message', (event) => {
@ -57,7 +56,17 @@ export const loadPlugin = async function (
return;
}
closeAllPlugins();
if (manifest.scope === 'global') {
if (
plugins.some(
(plugin) => plugin.manifest?.pluginId === manifest.pluginId,
)
) {
return;
}
} else {
closeAllPlugins();
}
// The host context is not deeply frozen at this load stage.
//
@ -74,7 +83,8 @@ export const loadPlugin = async function (
// `createSandbox`'s proxy handler applies `ses.safeReturn` to values
// crossing into the sandbox. Compartment isolation and intrinsics
// hardening are performed by createSandbox, not here.
const plugin = await createPlugin(
let plugin: Awaited<ReturnType<typeof createPlugin>> | undefined = undefined;
plugin = await createPlugin(
context,
manifest,
() => {
@ -88,7 +98,7 @@ export const loadPlugin = async function (
);
plugins.push(plugin);
} catch (error) {
closeAllPlugins();
if (manifest.scope !== 'global') closeAllPlugins();
throw error;
}
};

View File

@ -7,6 +7,7 @@ export const manifestSchema = z.object({
code: z.string(),
icon: z.string().optional(),
version: z.number().optional(),
scope: z.enum(['workspace', 'global']).optional(),
description: z.string().max(200).optional(),
permissions: z.array(
z.enum([

View File

@ -40,11 +40,10 @@ export async function createPluginManager(
modal?.setTheme(theme);
});
const listenerId: symbol = context.addListener('finish', () => {
closePlugin();
context?.removeListener(listenerId);
const listenerId = context.addListener('finish', () => {
if (manifest.scope !== 'global') closePlugin();
});
const logoutId = context.addListener('logout', () => closePlugin());
let listeners: symbol[] = [];
@ -60,7 +59,11 @@ export async function createPluginManager(
};
const closePlugin = () => {
if (destroyed) return;
destroyed = true;
removeAllEventListeners();
destroyListener(listenerId);
destroyListener(logoutId);
timeouts.forEach(clearTimeout);
timeouts.clear();
@ -74,8 +77,6 @@ export async function createPluginManager(
modal = null;
}
destroyed = true;
onCloseCallback();
};