fix(automation): allow a configurable canvas endpoint and browser-only token

- connectAutomation accepts an explicit ws:// URL and a register token
- EditorView reads ?automationWs / ?automationToken so a served build (W4C Design
  Studio iframe) can reach the server MCP instead of the user's 127.0.0.1
- MCP accepts OPENPENCIL_MCP_BROWSER_TOKEN for canvas registration only, never
  for /mcp or /rpc, so a public canvas WebSocket does not expose the tool token
This commit is contained in:
Vitali sharp8n 2026-09-18 03:37:20 +03:00
parent 683a70eace
commit b7fda1d406
5 changed files with 51 additions and 7 deletions

View file

@ -14,6 +14,13 @@ const APP_NOT_CONNECTED_MESSAGE =
type BrowserRPCBridgeOptions = { type BrowserRPCBridgeOptions = {
authToken: string | null authToken: string | null
/**
* Optional second token that only authorizes browser *canvas registration*
* (the `register` WebSocket message), never the HTTP `/mcp`/`/rpc` tool
* surface. W4C uses it so a served build can host a canvas over a public
* WebSocket without handing tenants the MCP tool token.
*/
browserToken?: string | null
onConnectionChange: () => void onConnectionChange: () => void
} }
@ -61,7 +68,7 @@ function createSettler<T>(resolve: (value: T) => void, reject: (error: Error) =>
} }
} }
export function createBrowserRPCBridge({ authToken, onConnectionChange }: BrowserRPCBridgeOptions) { export function createBrowserRPCBridge({ authToken, browserToken, onConnectionChange }: BrowserRPCBridgeOptions) {
const pending = new Map<string, PendingRequest>() const pending = new Map<string, PendingRequest>()
const clients = new Set<WebSocket>() const clients = new Set<WebSocket>()
const connectionWaiters = new Set<PendingRequest>() const connectionWaiters = new Set<PendingRequest>()
@ -216,7 +223,13 @@ export function createBrowserRPCBridge({ authToken, onConnectionChange }: Browse
function registerBrowser(ws: WebSocket, token: string | null) { function registerBrowser(ws: WebSocket, token: string | null) {
if (bridgeClosed) return if (bridgeClosed) return
if (!isAuthorized(token, authToken)) { // The canvas may authenticate with the main MCP token (desktop/dev, same
// trust boundary) or with the browser-only token (served build, cannot read
// the MCP token). Only the main token also unlocks the HTTP tool surface.
const authorized =
isAuthorized(token, authToken) ||
(browserToken !== null && browserToken !== undefined && isAuthorized(token, browserToken))
if (!authorized) {
ws.close() ws.close()
return return
} }

View file

@ -91,6 +91,9 @@ const handle = await startServer({
return trimmed return trimmed
})(), })(),
corsOrigin: process.env.OPENPENCIL_MCP_CORS_ORIGIN?.trim() || null, corsOrigin: process.env.OPENPENCIL_MCP_CORS_ORIGIN?.trim() || null,
// Optional browser-only token: authorizes canvas registration over the public
// WebSocket without exposing the MCP tool token to tenants.
browserToken: process.env.OPENPENCIL_MCP_BROWSER_TOKEN?.trim() || null,
appAttachTimeoutMs appAttachTimeoutMs
}) })

View file

@ -71,6 +71,12 @@ export interface ServerOptions {
mcpRoot?: string | null mcpRoot?: string | null
/** Auth token for /mcp and /rpc endpoints. Auto-generated (32-hex) when omitted. Pass null explicitly to disable auth. */ /** Auth token for /mcp and /rpc endpoints. Auto-generated (32-hex) when omitted. Pass null explicitly to disable auth. */
authToken?: string | null authToken?: string | null
/**
* Optional second token that authorizes only browser canvas registration over
* the WebSocket. Lets a served build host a canvas without exposing the MCP
* tool token. Ignored for /mcp and /rpc.
*/
browserToken?: string | null
corsOrigin?: string | null corsOrigin?: string | null
/** /**
* If set, the server starts a grace-period timer while no app is attached. * If set, the server starts a grace-period timer while no app is attached.
@ -305,6 +311,7 @@ function buildServerContext(options: ServerOptions) {
}) })
const browserRPC = createBrowserRPCBridge({ const browserRPC = createBrowserRPCBridge({
authToken, authToken,
browserToken: options.browserToken ?? null,
onConnectionChange: mcpSessions.notifyToolsChanged onConnectionChange: mcpSessions.notifyToolsChanged
}) })
const sendToBrowser = browserRPC.sendRPC const sendToBrowser = browserRPC.sendRPC

View file

@ -11,7 +11,11 @@ import { makeFigmaFromStore } from '@/app/automation/bridge/figma-factory'
import { createAutomationCommandHandlers } from '@/app/automation/bridge/handlers' import { createAutomationCommandHandlers } from '@/app/automation/bridge/handlers'
import type { EditorStore } from '@/app/editor/active-store' import type { EditorStore } from '@/app/editor/active-store'
export function connectAutomation(getStore: () => EditorStore, authToken: string | null = null) { export function connectAutomation(
getStore: () => EditorStore,
authToken: string | null = null,
wsUrl: string | null = null
) {
const token = authToken ?? randomHex(32) const token = authToken ?? randomHex(32)
let ws: WebSocket | null = null let ws: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | undefined let reconnectTimer: ReturnType<typeof setTimeout> | undefined
@ -27,7 +31,11 @@ export function connectAutomation(getStore: () => EditorStore, authToken: string
function connect() { function connect() {
let socket: WebSocket let socket: WebSocket
try { try {
socket = new WebSocket(`ws://127.0.0.1:${AUTOMATION_HTTP_PORT}`) // Served builds (W4C Design Studio iframe) cannot reach the MCP on
// 127.0.0.1 — that is the *user's* machine, not the server. The embedder
// passes the real endpoint via `?automationWs=`; desktop/dev keep the
// loopback default where editor and MCP share a host.
socket = new WebSocket(wsUrl ?? `ws://127.0.0.1:${AUTOMATION_HTTP_PORT}`)
ws = socket ws = socket
} catch (e) { } catch (e) {
console.error( console.error(

View file

@ -103,11 +103,24 @@ onMounted(async () => {
// reports "OpenPencil app is not connected" for every tool call. Allow an explicit opt-in so a // reports "OpenPencil app is not connected" for every tool call. Allow an explicit opt-in so a
// served build can host a canvas: build-time `VITE_OPENPENCIL_AUTOMATION=1`, or runtime // served build can host a canvas: build-time `VITE_OPENPENCIL_AUTOMATION=1`, or runtime
// `?automation=1`. Normal web users without the opt-in are unaffected. // `?automation=1`. Normal web users without the opt-in are unaffected.
const params = new URLSearchParams(window.location.search)
const automationOptIn = const automationOptIn =
import.meta.env.VITE_OPENPENCIL_AUTOMATION === '1' || import.meta.env.VITE_OPENPENCIL_AUTOMATION === '1' || params.get('automation') === '1'
new URLSearchParams(window.location.search).get('automation') === '1' // Served builds cannot reach the MCP on 127.0.0.1 (that is the *user's* machine, not the
// server). The embedder passes the reachable endpoint via `?automationWs=` plus a browser-only
// token via `?automationToken=` (the MCP tool token never leaves the backend). Desktop/dev keep
// the loopback default.
const automationWs =
params.get('automationWs') ||
(import.meta.env.VITE_OPENPENCIL_AUTOMATION_URL as string | undefined) ||
null
const automationToken = params.get('automationToken') || mcp?.authToken || null
if (import.meta.env.DEV || (tauri && mcp) || automationOptIn) { if (import.meta.env.DEV || (tauri && mcp) || automationOptIn) {
automationCleanup.value = connectAutomation(getActiveStore, mcp?.authToken ?? null).disconnect automationCleanup.value = connectAutomation(
getActiveStore,
automationToken,
automationWs
).disconnect
} }
try { try {