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:
parent
683a70eace
commit
b7fda1d406
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue