mirror of
https://github.com/penpot/penpot.git
synced 2026-09-11 13:20:03 -04:00
Compare commits
5
Commits
main
...
issue-11631
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54a0baa618 | ||
|
|
f9c02926b9 | ||
|
|
757a5bd479 | ||
|
|
4ce459d720 | ||
|
|
c589563912 |
No files matched your search
@@ -27,7 +27,7 @@ export PENPOT_MEDIA_PROCESSING_SERVICE_URI=http://localhost:6065
|
||||
export PENPOT_FLAGS="\
|
||||
$PENPOT_FLAGS \
|
||||
enable-login-with-password \
|
||||
disable-login-with-ldap \
|
||||
enable-login-with-ldap \
|
||||
disable-login-with-oidc \
|
||||
disable-login-with-google \
|
||||
disable-login-with-github \
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[clj-ldap.client :as ldap]
|
||||
[clojure.string]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(defn- prepare-params
|
||||
@@ -36,11 +36,22 @@
|
||||
:cause cause))))
|
||||
|
||||
(defn- replace-several [s & {:as replacements}]
|
||||
(reduce-kv clojure.string/replace s replacements))
|
||||
(reduce-kv str/replace s replacements))
|
||||
|
||||
(defn- escape-ldap-filter-value
|
||||
"Escapes special characters in a string for use in LDAP filter values,
|
||||
per RFC 4515 section 3."
|
||||
[s]
|
||||
(-> s
|
||||
(str/replace "\\" "\\5c")
|
||||
(str/replace "*" "\\2a")
|
||||
(str/replace "(" "\\28")
|
||||
(str/replace ")" "\\29")
|
||||
(str/replace "\u0000" "\\00")))
|
||||
|
||||
(defn- search-user
|
||||
[{:keys [::conn base-dn] :as cfg} email]
|
||||
(let [query (replace-several (:query cfg) ":username" email)
|
||||
(let [query (replace-several (:query cfg) ":username" (escape-ldap-filter-value email))
|
||||
attrs [(:attrs-username cfg)
|
||||
(:attrs-email cfg)
|
||||
(:attrs-fullname cfg)]
|
||||
@@ -49,12 +60,19 @@
|
||||
:attributes attrs}]
|
||||
(first (ldap/search conn base-dn params))))
|
||||
|
||||
(defn- get-attr
|
||||
"Retrieves an attribute from an LDAP entry. Handles multi-valued
|
||||
attributes by returning the first value."
|
||||
[entry attr-key]
|
||||
(let [v (get entry attr-key)]
|
||||
(if (coll? v) (first v) v)))
|
||||
|
||||
(defn- retrieve-user
|
||||
[{:keys [::conn] :as cfg} {:keys [email password]}]
|
||||
(when-let [{:keys [dn] :as user} (search-user cfg email)]
|
||||
(when (ldap/bind? conn dn password)
|
||||
{:fullname (get user (-> cfg :attrs-fullname keyword))
|
||||
:email email
|
||||
{:fullname (get-attr user (-> cfg :attrs-fullname keyword))
|
||||
:email (get-attr user (-> cfg :attrs-email keyword))
|
||||
:backend "ldap"})))
|
||||
|
||||
(def ^:private schema:info-data
|
||||
@@ -79,7 +97,7 @@
|
||||
(l/warn :hint "invalid response from ldap, looks like ldap is not configured correctly" :data user)
|
||||
(ex/raise :type :restriction
|
||||
:code :wrong-ldap-response
|
||||
:explain explain)))
|
||||
::sm/explain explain)))
|
||||
user)))
|
||||
|
||||
(defn- try-connectivity
|
||||
|
||||
@@ -60,7 +60,13 @@
|
||||
|
||||
(defmethod handle-error :restriction
|
||||
[err request _]
|
||||
(let [{:keys [code] :as data} (ex-data err)]
|
||||
(let [data (ex-data err)
|
||||
code (get data :code)
|
||||
explain (ex/explain data)
|
||||
data (-> data
|
||||
(dissoc ::sm/explain)
|
||||
(cond-> explain (assoc :explain explain)))]
|
||||
|
||||
(if (= code :method-not-allowed)
|
||||
{::yres/status 405
|
||||
::yres/body data}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
;; 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 INC Sucursal en España SL
|
||||
|
||||
(ns backend-tests.auth-ldap-test
|
||||
(:require
|
||||
[app.auth.ldap :as ldap-auth]
|
||||
[clj-ldap.client :as ldap]
|
||||
[clojure.test :as t]))
|
||||
|
||||
;; --- search-user: filter must be escaped (RED: currently not escaped)
|
||||
|
||||
(t/deftest search-user-escapes-email-in-filter
|
||||
(t/testing "wildcard * is escaped before building LDAP filter"
|
||||
(let [captured-query (atom nil)
|
||||
fake-search (fn [_conn _base-dn params]
|
||||
(reset! captured-query (:filter params))
|
||||
[])]
|
||||
(with-redefs [ldap/search fake-search]
|
||||
(#'ldap-auth/search-user {:query "(mail=:username)" :sizelimit 1
|
||||
:attrs-username "uid" :attrs-email "mail"
|
||||
:attrs-fullname "cn"}
|
||||
"fry*@planetexpress.com"))
|
||||
;; After fix: * should be escaped as \2a
|
||||
(t/is (= "(mail=fry\\2a@planetexpress.com)" @captured-query)
|
||||
"filter must have * escaped per RFC 4515"))))
|
||||
|
||||
;; --- retrieve-user: email must come from directory, not client (RED)
|
||||
|
||||
(t/deftest retrieve-user-uses-directory-email
|
||||
(t/testing "returned email is from LDAP directory, not client input"
|
||||
(let [fake-search (fn [_conn _base-dn _params]
|
||||
[{:dn "cn=fry,ou=people,dc=planetexpress,dc=com"
|
||||
:mail "fry@planetexpress.com"
|
||||
:cn "Philip J. Fry"
|
||||
:uid "fry"}])
|
||||
fake-bind? (fn [_conn _dn _password] true)]
|
||||
(with-redefs [ldap/search fake-search
|
||||
ldap/bind? fake-bind?]
|
||||
(let [cfg {:query "(mail=:username)" :sizelimit 1
|
||||
:attrs-username "uid" :attrs-email "mail"
|
||||
:attrs-fullname "cn"}
|
||||
result (#'ldap-auth/retrieve-user cfg {:email "fry*@planetexpress.com" :password "fry"})]
|
||||
;; After fix: email should be from directory (fry@planetexpress.com)
|
||||
;; BUG: email is client input (fry*@planetexpress.com)
|
||||
(t/is (= "fry@planetexpress.com" (:email result))
|
||||
"email must come from LDAP directory attribute, not client input"))))))
|
||||
|
||||
;; --- authenticate: full flow with directory email (RED)
|
||||
|
||||
(t/deftest authenticate-returns-directory-email
|
||||
(t/testing "authenticate returns directory email for profile"
|
||||
(let [fake-search (fn [_conn _base-dn _params]
|
||||
[{:dn "cn=amy,ou=people,dc=planetexpress,dc=com"
|
||||
:mail "amy@planetexpress.com"
|
||||
:cn "Amy Wong"
|
||||
:uid "amy"}])
|
||||
fake-bind? (fn [_conn _dn _password] true)]
|
||||
(with-redefs [ldap/search fake-search
|
||||
ldap/bind? fake-bind?
|
||||
ldap/connect (fn [_cfg] (reify java.lang.AutoCloseable (close [_] nil)))]
|
||||
(let [cfg {:query "(mail=:username)" :sizelimit 1
|
||||
:attrs-username "uid" :attrs-email "mail"
|
||||
:attrs-fullname "cn"
|
||||
:bind-dn "cn=admin,dc=planetexpress,dc=com"
|
||||
:bind-password "GoodNewsEveryone"
|
||||
:host "localhost" :port 10389
|
||||
:ssl false :tls false
|
||||
:base-dn "ou=people,dc=planetexpress,dc=com"}
|
||||
result (ldap-auth/authenticate cfg {:email "*@planetexpress.com" :password "amy"})]
|
||||
;; After fix: email should be amy@planetexpress.com (directory)
|
||||
;; BUG: email is *@planetexpress.com (client)
|
||||
(t/is (= "amy@planetexpress.com" (:email result))
|
||||
"authenticate must return directory email, not client-supplied wildcard"))))))
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { rpcPost, extractCookie } from "./helpers/client.mjs";
|
||||
|
||||
async function loginWithLdap(email, password) {
|
||||
const res = await rpcPost("login-with-ldap", { email, password });
|
||||
if (res.status !== 200 || res.body.type) {
|
||||
throw new Error(
|
||||
`LDAP login failed: ${JSON.stringify(res.body)}`
|
||||
);
|
||||
}
|
||||
const cookie = extractCookie(res.setCookie);
|
||||
return { profile: res.body, cookie };
|
||||
}
|
||||
|
||||
describe("LDAP injection — T5-N1-03", () => {
|
||||
|
||||
it("normal LDAP login works with valid credentials", async () => {
|
||||
const { profile, cookie } = await loginWithLdap(
|
||||
"fry@planetexpress.com",
|
||||
"fry"
|
||||
);
|
||||
assert.equal(profile.email, "fry@planetexpress.com");
|
||||
assert.ok(profile.id, "profile should have id");
|
||||
assert.ok(cookie, "cookie should be set");
|
||||
});
|
||||
|
||||
it("wildcard injection: *@planetexpress.com must not return client literal as email", async () => {
|
||||
// ATTACK SCENARIO (from Criptored audit):
|
||||
// 1. Attacker (amy) sends email="*@planetexpress.com" with her own password
|
||||
// 2. LDAP filter becomes (mail=*@planetexpress.com) — * is a wildcard
|
||||
// 3. With sizelimit=1, LDAP returns amy's entry (first match)
|
||||
// 4. Bind succeeds: amy's DN + amy's password = valid
|
||||
//
|
||||
// EXPECTED BEHAVIOR AFTER FIX (two valid outcomes):
|
||||
// A) If * is escaped: LDAP finds no match → wrong-credentials (injection blocked)
|
||||
// B) If * matches: profile email must be "amy@planetexpress.com" (directory), not "*@planetexpress.com" (client)
|
||||
//
|
||||
// Either outcome is correct — the vulnerability is fixed.
|
||||
try {
|
||||
const { profile } = await loginWithLdap("*@planetexpress.com", "amy");
|
||||
// Outcome B: login succeeded, verify email is from directory
|
||||
assert.equal(
|
||||
profile.email,
|
||||
"amy@planetexpress.com",
|
||||
"email must come from LDAP directory, not client input"
|
||||
);
|
||||
} catch (e) {
|
||||
// Outcome A: injection blocked — * is escaped, no LDAP match
|
||||
assert.ok(
|
||||
e.message.includes("wrong-credentials"),
|
||||
"wildcard should be rejected or return directory email"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("identity swap: alternate email must return primary directory email", async () => {
|
||||
// Professor has two emails in LDAP: professor@ and hubert@.
|
||||
// Login with hubert@ — the profile email should be the one
|
||||
// the LDAP directory returns as attrs-email, not what the client typed.
|
||||
//
|
||||
// EXPECTED BEHAVIOR AFTER FIX:
|
||||
// Profile email should be "professor@planetexpress.com" (primary directory email),
|
||||
// NOT "hubert@planetexpress.com" (client literal).
|
||||
//
|
||||
// CURRENT BUG: email is "hubert@planetexpress.com" (client literal) — test FAILS
|
||||
const { profile, cookie } = await loginWithLdap(
|
||||
"hubert@planetexpress.com",
|
||||
"professor"
|
||||
);
|
||||
assert.ok(profile.id, "profile should have id");
|
||||
assert.ok(cookie, "cookie should be set");
|
||||
// This assertion FAILS with current code (RED) — proves the vulnerability
|
||||
assert.equal(
|
||||
profile.email,
|
||||
"professor@planetexpress.com",
|
||||
"email must come from LDAP directory, not client input"
|
||||
);
|
||||
});
|
||||
|
||||
it("wrong password fails", async () => {
|
||||
try {
|
||||
await loginWithLdap("fry@planetexpress.com", "wrong-password");
|
||||
assert.fail("should have thrown");
|
||||
} catch (e) {
|
||||
assert.ok(
|
||||
e.message.includes("LDAP login failed") ||
|
||||
e.message.includes("wrong-credentials"),
|
||||
"should fail with wrong credentials"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("non-existent user fails", async () => {
|
||||
try {
|
||||
await loginWithLdap("nobody@planetexpress.com", "password");
|
||||
assert.fail("should have thrown");
|
||||
} catch (e) {
|
||||
assert.ok(
|
||||
e.message.includes("LDAP login failed") ||
|
||||
e.message.includes("wrong-credentials"),
|
||||
"should fail for non-existent user"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1770,7 +1770,7 @@ msgstr "At least 1 uppercase letter"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.insufficient-digits"
|
||||
msgstr "At least 1 digit"
|
||||
msgstr "At least 1 number"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.insufficient-special"
|
||||
|
||||
@@ -1735,7 +1735,7 @@ msgstr "Al menos 1 letra mayúscula"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.insufficient-digits"
|
||||
msgstr "Al menos 1 dígito"
|
||||
msgstr "Al menos 1 número"
|
||||
|
||||
#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs
|
||||
msgid "errors.weak-password.insufficient-special"
|
||||
|
||||
+2
-1
@@ -265,7 +265,8 @@ The Penpot MCP server can be configured using environment variables.
|
||||
| `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` |
|
||||
| `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` |
|
||||
| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` |
|
||||
| `PENPOT_MCP_REPL_ENABLE` | Explicitly enable/disable the REPL server. Set to `true` to enable. When unset, defaults to the value of `PENPOT_MCP_DEVENV`. | (unset) |
|
||||
| `PENPOT_MCP_REPL_HOST` | Address on which the REPL server listens (binds to) | `localhost` |
|
||||
| `PENPOT_MCP_REPL_ENABLE` | Explicitly enable/disable the REPL server. Set to `true` to enable. When unset, defaults to the value of `PENPOT_MCP_DEVENV`. The REPL server never starts in multi-user mode. | (unset) |
|
||||
| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` |
|
||||
| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools in local single-user mode. Set to `true` to enable. | `false` |
|
||||
| `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { PenpotMcpServer, shouldRegisterDeveloperTools } from "./PenpotMcpServer";
|
||||
import { PenpotMcpServer, shouldRegisterDeveloperTools, shouldStartReplServer } from "./PenpotMcpServer";
|
||||
|
||||
test("registers developer tools in local devenv mode", () => {
|
||||
assert.equal(shouldRegisterDeveloperTools(true, false), true);
|
||||
@@ -14,6 +14,22 @@ test("does not register developer tools when devenv mode is disabled", () => {
|
||||
assert.equal(shouldRegisterDeveloperTools(false, false), false);
|
||||
});
|
||||
|
||||
test("starts REPL server in single-user mode when enabled", () => {
|
||||
assert.equal(shouldStartReplServer(true, false), true);
|
||||
});
|
||||
|
||||
test("does not start REPL server in multi-user mode even when enabled", () => {
|
||||
assert.equal(shouldStartReplServer(true, true), false);
|
||||
});
|
||||
|
||||
test("does not start REPL server when disabled in single-user mode", () => {
|
||||
assert.equal(shouldStartReplServer(false, false), false);
|
||||
});
|
||||
|
||||
test("does not start REPL server when disabled in multi-user mode", () => {
|
||||
assert.equal(shouldStartReplServer(false, true), false);
|
||||
});
|
||||
|
||||
// ── Pure function tests ────────────────────────────────────────
|
||||
|
||||
test("isDevEnvEnabled returns false when PENPOT_MCP_DEVENV is not set", () => {
|
||||
@@ -125,6 +141,79 @@ test("constructor does not create ReplServer when PENPOT_MCP_REPL_ENABLE is 'fal
|
||||
}
|
||||
});
|
||||
|
||||
test("constructor does not create ReplServer in multi-user mode even with DEVENV", async () => {
|
||||
const prevDevEnv = process.env.PENPOT_MCP_DEVENV;
|
||||
const prevReplEnable = process.env.PENPOT_MCP_REPL_ENABLE;
|
||||
const prevPorts = setUniqueEnv();
|
||||
process.env.PENPOT_MCP_DEVENV = "true";
|
||||
delete process.env.PENPOT_MCP_REPL_ENABLE;
|
||||
let server: PenpotMcpServer | undefined;
|
||||
try {
|
||||
server = new PenpotMcpServer(true);
|
||||
assert.equal(server.hasReplServer(), false);
|
||||
} finally {
|
||||
await server?.stop();
|
||||
restoreEnv(prevDevEnv, prevPorts);
|
||||
restoreOrDelete("PENPOT_MCP_REPL_ENABLE", prevReplEnable);
|
||||
}
|
||||
});
|
||||
|
||||
test("constructor does not create ReplServer in multi-user mode even with explicit REPL_ENABLE", async () => {
|
||||
const prevDevEnv = process.env.PENPOT_MCP_DEVENV;
|
||||
const prevReplEnable = process.env.PENPOT_MCP_REPL_ENABLE;
|
||||
const prevPorts = setUniqueEnv();
|
||||
delete process.env.PENPOT_MCP_DEVENV;
|
||||
process.env.PENPOT_MCP_REPL_ENABLE = "true";
|
||||
let server: PenpotMcpServer | undefined;
|
||||
try {
|
||||
server = new PenpotMcpServer(true);
|
||||
assert.equal(server.hasReplServer(), false);
|
||||
} finally {
|
||||
await server?.stop();
|
||||
restoreEnv(prevDevEnv, prevPorts);
|
||||
restoreOrDelete("PENPOT_MCP_REPL_ENABLE", prevReplEnable);
|
||||
}
|
||||
});
|
||||
|
||||
test("replHost defaults to localhost and ignores SERVER_HOST", async () => {
|
||||
const prevDevEnv = process.env.PENPOT_MCP_DEVENV;
|
||||
const prevServerHost = process.env.PENPOT_MCP_SERVER_HOST;
|
||||
const prevReplHost = process.env.PENPOT_MCP_REPL_HOST;
|
||||
const prevPorts = setUniqueEnv();
|
||||
process.env.PENPOT_MCP_DEVENV = "true";
|
||||
process.env.PENPOT_MCP_SERVER_HOST = "0.0.0.0";
|
||||
delete process.env.PENPOT_MCP_REPL_HOST;
|
||||
let server: PenpotMcpServer | undefined;
|
||||
try {
|
||||
server = new PenpotMcpServer(false);
|
||||
assert.equal(server.hasReplServer(), true);
|
||||
assert.equal(server.replHost, "localhost");
|
||||
} finally {
|
||||
await server?.stop();
|
||||
restoreEnv(prevDevEnv, prevPorts);
|
||||
restoreOrDelete("PENPOT_MCP_SERVER_HOST", prevServerHost);
|
||||
restoreOrDelete("PENPOT_MCP_REPL_HOST", prevReplHost);
|
||||
}
|
||||
});
|
||||
|
||||
test("replHost respects PENPOT_MCP_REPL_HOST", async () => {
|
||||
const prevDevEnv = process.env.PENPOT_MCP_DEVENV;
|
||||
const prevReplHost = process.env.PENPOT_MCP_REPL_HOST;
|
||||
const prevPorts = setUniqueEnv();
|
||||
process.env.PENPOT_MCP_DEVENV = "true";
|
||||
process.env.PENPOT_MCP_REPL_HOST = "0.0.0.0";
|
||||
let server: PenpotMcpServer | undefined;
|
||||
try {
|
||||
server = new PenpotMcpServer(false);
|
||||
assert.equal(server.hasReplServer(), true);
|
||||
assert.equal(server.replHost, "0.0.0.0");
|
||||
} finally {
|
||||
await server?.stop();
|
||||
restoreEnv(prevDevEnv, prevPorts);
|
||||
restoreOrDelete("PENPOT_MCP_REPL_HOST", prevReplHost);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
function setUniqueEnv() {
|
||||
@@ -132,15 +221,22 @@ function setUniqueEnv() {
|
||||
const prevServer = process.env.PENPOT_MCP_SERVER_PORT;
|
||||
const prevWs = process.env.PENPOT_MCP_WEBSOCKET_PORT;
|
||||
const prevRepl = process.env.PENPOT_MCP_REPL_PORT;
|
||||
const prevReplHost = process.env.PENPOT_MCP_REPL_HOST;
|
||||
process.env.PENPOT_MCP_SERVER_PORT = String(ports.server);
|
||||
process.env.PENPOT_MCP_WEBSOCKET_PORT = String(ports.ws);
|
||||
process.env.PENPOT_MCP_REPL_PORT = String(ports.repl);
|
||||
return { prevServer, prevWs, prevRepl };
|
||||
delete process.env.PENPOT_MCP_REPL_HOST;
|
||||
return { prevServer, prevWs, prevRepl, prevReplHost };
|
||||
}
|
||||
|
||||
function restoreEnv(
|
||||
devEnv: string | undefined,
|
||||
ports: { prevServer: string | undefined; prevWs: string | undefined; prevRepl: string | undefined }
|
||||
ports: {
|
||||
prevServer: string | undefined;
|
||||
prevWs: string | undefined;
|
||||
prevRepl: string | undefined;
|
||||
prevReplHost: string | undefined;
|
||||
}
|
||||
) {
|
||||
if (devEnv !== undefined) {
|
||||
process.env.PENPOT_MCP_DEVENV = devEnv;
|
||||
@@ -150,6 +246,7 @@ function restoreEnv(
|
||||
restoreOrDelete("PENPOT_MCP_SERVER_PORT", ports.prevServer);
|
||||
restoreOrDelete("PENPOT_MCP_WEBSOCKET_PORT", ports.prevWs);
|
||||
restoreOrDelete("PENPOT_MCP_REPL_PORT", ports.prevRepl);
|
||||
restoreOrDelete("PENPOT_MCP_REPL_HOST", ports.prevReplHost);
|
||||
}
|
||||
|
||||
function restoreOrDelete(key: string, value: string | undefined) {
|
||||
|
||||
@@ -57,6 +57,16 @@ export function shouldRegisterDeveloperTools(isDevEnv: boolean, isMultiUserMode:
|
||||
return isDevEnv && !isMultiUserMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the REPL server may be started for the current server mode.
|
||||
*
|
||||
* The REPL server never starts in multi-user mode, even when explicitly
|
||||
* enabled, mirroring the developer tools policy.
|
||||
*/
|
||||
export function shouldStartReplServer(isReplEnabled: boolean, isMultiUserMode: boolean): boolean {
|
||||
return isReplEnabled && !isMultiUserMode;
|
||||
}
|
||||
|
||||
export class PenpotMcpServer {
|
||||
/**
|
||||
* Timeout, in minutes, for idle sessions (Streamable HTTP and SSE) before they are automatically closed and removed.
|
||||
@@ -133,6 +143,7 @@ export class PenpotMcpServer {
|
||||
public readonly host: string;
|
||||
public readonly port: number;
|
||||
public readonly webSocketPort: number;
|
||||
public readonly replHost: string;
|
||||
public readonly replPort: number;
|
||||
private sessionTimeoutInterval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
@@ -156,6 +167,7 @@ export class PenpotMcpServer {
|
||||
this.host = process.env.PENPOT_MCP_SERVER_HOST ?? "localhost";
|
||||
this.port = parseInt(process.env.PENPOT_MCP_SERVER_PORT ?? "4401", 10);
|
||||
this.webSocketPort = parseInt(process.env.PENPOT_MCP_WEBSOCKET_PORT ?? "4402", 10);
|
||||
this.replHost = process.env.PENPOT_MCP_REPL_HOST ?? "localhost";
|
||||
this.replPort = parseInt(process.env.PENPOT_MCP_REPL_PORT ?? "4403", 10);
|
||||
this.tenant = process.env.PENPOT_TENANT ?? "default";
|
||||
const toolTimeoutSecs = parseInt(process.env.PENPOT_MCP_TOOL_TIMEOUT_S ?? "120", 10);
|
||||
@@ -181,8 +193,8 @@ export class PenpotMcpServer {
|
||||
|
||||
this.pluginBridge = new PluginBridge(this, this.webSocketPort, toolTimeoutSecs, this.redisBridge);
|
||||
|
||||
if (PenpotMcpServer.isReplEnabled(process.env)) {
|
||||
this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host);
|
||||
if (shouldStartReplServer(PenpotMcpServer.isReplEnabled(process.env), this.isMultiUserMode())) {
|
||||
this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.replHost);
|
||||
} else {
|
||||
this.replServer = null;
|
||||
}
|
||||
@@ -232,9 +244,10 @@ export class PenpotMcpServer {
|
||||
/**
|
||||
* Indicates whether the REPL server was created.
|
||||
*
|
||||
* The REPL server is created when {@link isReplEnabled} returns true,
|
||||
* which means either ``PENPOT_MCP_REPL_ENABLE=true`` or, when that
|
||||
* variable is unset, ``PENPOT_MCP_DEVENV=true``.
|
||||
* The REPL server is created when {@link isReplEnabled} returns true and
|
||||
* the server is not running in multi-user mode, which means either
|
||||
* ``PENPOT_MCP_REPL_ENABLE=true`` or, when that variable is unset,
|
||||
* ``PENPOT_MCP_DEVENV=true``, in single-user mode.
|
||||
*/
|
||||
public hasReplServer(): boolean {
|
||||
return this.replServer !== null;
|
||||
@@ -471,6 +484,8 @@ export class PenpotMcpServer {
|
||||
// start the REPL server (devenv only) and session timeout checker
|
||||
if (this.replServer) {
|
||||
await this.replServer.start();
|
||||
} else if (this.isMultiUserMode()) {
|
||||
this.logger.info("REPL server disabled in multi-user mode (never started with --multi-user)");
|
||||
} else {
|
||||
this.logger.info(
|
||||
"REPL server disabled (set PENPOT_MCP_REPL_ENABLE=true or PENPOT_MCP_DEVENV=true to enable)"
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# This starts the MCP server in a configuration for Penpot development
|
||||
# (assuming devenv)
|
||||
|
||||
PENPOT_MCP_SERVER_HOST=0.0.0.0 PENPOT_MCP_REMOTE_MODE=true PENPOT_MCP_DEVENV=true pnpm run bootstrap
|
||||
PENPOT_MCP_SERVER_HOST=0.0.0.0 PENPOT_MCP_REPL_HOST=0.0.0.0 PENPOT_MCP_REMOTE_MODE=true PENPOT_MCP_DEVENV=true pnpm run bootstrap
|
||||
Reference in new issue
Block a user