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 = {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -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 clients = new Set<WebSocket>()
|
||||
const connectionWaiters = new Set<PendingRequest>()
|
||||
|
|
@ -216,7 +223,13 @@ export function createBrowserRPCBridge({ authToken, onConnectionChange }: Browse
|
|||
|
||||
function registerBrowser(ws: WebSocket, token: string | null) {
|
||||
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()
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,9 @@ const handle = await startServer({
|
|||
return trimmed
|
||||
})(),
|
||||
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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,12 @@ export interface ServerOptions {
|
|||
mcpRoot?: string | null
|
||||
/** Auth token for /mcp and /rpc endpoints. Auto-generated (32-hex) when omitted. Pass null explicitly to disable auth. */
|
||||
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
|
||||
/**
|
||||
* 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({
|
||||
authToken,
|
||||
browserToken: options.browserToken ?? null,
|
||||
onConnectionChange: mcpSessions.notifyToolsChanged
|
||||
})
|
||||
const sendToBrowser = browserRPC.sendRPC
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ import { makeFigmaFromStore } from '@/app/automation/bridge/figma-factory'
|
|||
import { createAutomationCommandHandlers } from '@/app/automation/bridge/handlers'
|
||||
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)
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
|
@ -27,7 +31,11 @@ export function connectAutomation(getStore: () => EditorStore, authToken: string
|
|||
function connect() {
|
||||
let socket: WebSocket
|
||||
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
|
||||
} catch (e) {
|
||||
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
|
||||
// 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.
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const automationOptIn =
|
||||
import.meta.env.VITE_OPENPENCIL_AUTOMATION === '1' ||
|
||||
new URLSearchParams(window.location.search).get('automation') === '1'
|
||||
import.meta.env.VITE_OPENPENCIL_AUTOMATION === '1' || params.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) {
|
||||
automationCleanup.value = connectAutomation(getActiveStore, mcp?.authToken ?? null).disconnect
|
||||
automationCleanup.value = connectAutomation(
|
||||
getActiveStore,
|
||||
automationToken,
|
||||
automationWs
|
||||
).disconnect
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in a new issue