From 42ddc412bbe6a513c08c173c32a5c4c033ec9820 Mon Sep 17 00:00:00 2001 From: Benjamin Bisgaard Krogh Date: Wed, 9 Sep 2026 14:33:56 +0200 Subject: [PATCH 1/2] Add standalone AX control bridge Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 8 ++ skills/pmx-canvas/SKILL.md | 8 +- .../references/ax-html-control-surface.md | 11 +++ src/server/html-surface.ts | 64 ++++++++++++- src/server/server.ts | 92 ++++++++++++++++++- tests/unit/server-api.test.ts | 56 +++++++++++ 6 files changed, 232 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adf7fb4f..0de13e4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to `pmx-canvas` are documented here. This project follows [Semantic Versioning](https://semver.org/). +## Unreleased + +### Added + +- AX-enabled HTML nodes opened as standalone browser surfaces receive short-lived, node-scoped + grants, so their existing `window.PMX_AX` controls and live state work outside the canvas iframe + without weakening node capability validation. + ## [0.6.2] - 2026-09-09 ### Changed diff --git a/skills/pmx-canvas/SKILL.md b/skills/pmx-canvas/SKILL.md index 9221036f..01433647 100644 --- a/skills/pmx-canvas/SKILL.md +++ b/skills/pmx-canvas/SKILL.md @@ -462,10 +462,10 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout. - Hosted MCP-app/ext-app nodes such as Excalidraw require the in-canvas host bridge and are not standalone **Open as site** targets. URL-backed viewers and bundled web artifacts remain openable. -- A standalone html surface (`/api/canvas/surface/:id` opened as a site) is a VISUAL view: it - renders the same content and theme, but `window.PMX_AX` is not injected without the canvas - iframe's per-mount nonce, so AX buttons only work inside the in-canvas node (0.4.4 Codex note). - Do not tell a user a standalone tab's controls will steer the agent. +- An AX-enabled standalone html surface (`/api/canvas/surface/:id` opened as a site) receives a + short-lived, node-scoped control grant. Its `window.PMX_AX` bridge can emit only the capabilities + enabled on that node, and the server re-validates every interaction. State is refreshed while the + tab remains open. Reload after a daemon restart because in-memory standalone grants are revoked. - A hosted ext-app (Excalidraw) node in a **WebKit** host panel (e.g. the GitHub Copilot app's embedded WKWebView) historically could render as a black tile — a host compositor paint race on the nested iframe, **not** a broken node (the session is healthy, `sessionStatus` is diff --git a/skills/pmx-canvas/references/ax-html-control-surface.md b/skills/pmx-canvas/references/ax-html-control-surface.md index a2b44f7f..bfee8cb7 100644 --- a/skills/pmx-canvas/references/ax-html-control-surface.md +++ b/skills/pmx-canvas/references/ax-html-control-surface.md @@ -93,3 +93,14 @@ ceiling). Flip an existing node on with `canvas_node({ action: "update", id, axC Allowed `type`s are gated per node capability (see the node-capability matrix in `SKILL.md`). Emits are clamped to the surface's own node; the server re-validates every interaction — the bridge is convenience, not a trust boundary. + +## Standalone browser control + +Opening an AX-enabled HTML node through `/api/canvas/surface/:nodeId` now creates a short-lived, +node-scoped browser grant. The page receives the same `window.PMX_AX` contract and a refreshed AX +state snapshot even though its CSP sandbox keeps an opaque origin. The grant cannot target another +node or exceed that node's configured capabilities, and every interaction still passes through +the normal server-side capability validation. + +Standalone grants live in server memory for 12 hours. Reload the page after the daemon restarts or +when a grant expires. HTML nodes without enabled AX capabilities remain visual-only. diff --git a/src/server/html-surface.ts b/src/server/html-surface.ts index 5266da3b..28b9ff93 100644 --- a/src/server/html-surface.ts +++ b/src/server/html-surface.ts @@ -171,6 +171,62 @@ export function buildAxBridge(axToken: string, nodeId: string): string { `; } +/** + * Top-level "Open as site" bridge. Standalone surfaces have no parent canvas + * to relay postMessage traffic, and their CSP sandbox gives them an opaque + * origin. A short-lived server grant therefore scopes direct CORS requests to + * this node while applyAxInteraction still enforces the node capability ceiling. + */ +export function buildStandaloneAxBridge(axToken: string, nodeId: string): string { + const token = JSON.stringify(axToken); + const node = JSON.stringify(nodeId); + return ``; +} + /** * Read-side bridge: seeds `window.PMX_AX.state` with a snapshot of the canvas AX * state and keeps it live via nonce-validated `ax-update` messages from the parent @@ -247,6 +303,8 @@ export interface HtmlSurfaceOptions { axBridge?: boolean; /** Nonce authorizing iframe → parent AX emits; embedded in the bridge. */ axToken?: string; + /** Server-minted grant for an AX-enabled top-level standalone surface. */ + standaloneAxToken?: string; /** Node id stamped on emitted interactions. */ nodeId?: string; /** @@ -282,7 +340,11 @@ export function buildHtmlSurfaceDocument(userHtml: string, options: HtmlSurfaceO const presentationBridge = options.presentation ? buildPresentationEscapeBridge(sanitizeToken(options.presentationExitToken)) : ''; - const axBridge = options.axBridge ? buildAxBridge(sanitizeToken(options.axToken), sanitizeToken(options.nodeId)) : ''; + const axBridge = options.axBridge + ? options.standaloneAxToken + ? buildStandaloneAxBridge(sanitizeToken(options.standaloneAxToken), sanitizeToken(options.nodeId)) + : buildAxBridge(sanitizeToken(options.axToken), sanitizeToken(options.nodeId)) + : ''; // Read-side AX state bridge (seed + live push). `" can't break out of the inline script. const axStateBridge = options.axBridge diff --git a/src/server/server.ts b/src/server/server.ts index 21995aaa..fc04b143 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -61,7 +61,7 @@ import { findOpenCanvasPosition } from './placement.js'; import { mutationHistory } from './mutation-history.js'; import { buildAgentContextPreamble } from './agent-context.js'; import { buildCanvasAxSurfaceSnapshot } from './ax-context.js'; -import { resolveNodeAxCapabilities } from './ax-interaction.js'; +import { applyAxInteraction, resolveNodeAxCapabilities } from './ax-interaction.js'; import { normalizeCanvasTheme, type CanvasTheme } from './canvas-db.js'; import { canvasThemeScheme, isCanvasTheme } from '../shared/themes.js'; import { canOpenNodeAsSurface } from '../shared/surface.js'; @@ -1398,6 +1398,87 @@ function surfaceRedirect(target: string): Response { return new Response(null, { status: 302, headers: { Location: target, 'Cache-Control': 'no-store' } }); } +const STANDALONE_AX_GRANT_TTL_MS = 12 * 60 * 60 * 1_000; +const standaloneAxGrants = new Map(); + +function mintStandaloneAxGrant(nodeId: string): string { + const now = Date.now(); + for (const [token, grant] of standaloneAxGrants) { + if (grant.expiresAt <= now) standaloneAxGrants.delete(token); + } + const token = randomUUID(); + standaloneAxGrants.set(token, { nodeId, expiresAt: now + STANDALONE_AX_GRANT_TTL_MS }); + return token; +} + +function validateStandaloneAxGrant(token: string, nodeId: string): boolean { + const grant = standaloneAxGrants.get(token); + if (!grant || grant.nodeId !== nodeId || grant.expiresAt <= Date.now()) { + if (grant) standaloneAxGrants.delete(token); + return false; + } + return true; +} + +function standaloneAxResponse(data: unknown, status = 200): Response { + return Response.json(data, { + status, + headers: { + 'Access-Control-Allow-Origin': 'null', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Cache-Control': 'no-store', + Vary: 'Origin', + }, + }); +} + +async function handleStandaloneAxRequest(req: Request, url: URL): Promise { + if (req.method === 'OPTIONS') return standaloneAxResponse({ ok: true }); + if (req.headers.get('origin') !== 'null') { + return standaloneAxResponse( + { ok: false, code: 'invalid-origin', error: 'Standalone AX requires an opaque sandbox origin.' }, + 403, + ); + } + + const match = url.pathname.match(/^\/api\/canvas\/surface-ax\/([^/]+)\/(state|interaction)$/); + if (!match) + return standaloneAxResponse({ ok: false, code: 'invalid-route', error: 'Unknown standalone AX route.' }, 404); + const nodeId = decodeURIComponent(match[1]); + const action = match[2]; + const token = url.searchParams.get('token') ?? ''; + if (!validateStandaloneAxGrant(token, nodeId)) { + return standaloneAxResponse( + { ok: false, code: 'invalid-grant', error: 'Standalone AX grant is invalid or expired.' }, + 403, + ); + } + + if (action === 'state' && req.method === 'GET') { + return standaloneAxResponse(buildCanvasAxSurfaceSnapshot()); + } + if (action !== 'interaction' || req.method !== 'POST') { + return standaloneAxResponse({ ok: false, code: 'method-not-allowed', error: 'Method not allowed.' }, 405); + } + + const body = await readJson(req); + if (body === null) + return standaloneAxResponse({ ok: false, code: 'invalid-json', error: 'Request body must be valid JSON.' }, 400); + const { result, events } = applyAxInteraction( + canvasState, + { + type: body.type, + sourceNodeId: nodeId, + payload: body.payload, + sourceSurface: 'html-node', + }, + 'browser', + ); + for (const event of events) emitPrimaryWorkbenchEvent(event.event, event.payload); + return standaloneAxResponse(result, result.ok ? 200 : result.status); +} + // Permit only absolute http(s) URLs and root-relative same-origin paths. Blocks // `javascript:`/`data:` and protocol-relative `//host` open-redirects. function isSafeSurfaceRedirect(target: string): boolean { @@ -1459,6 +1540,8 @@ function handleNodeSurface(pathname: string, url: URL): Response { const present = url.searchParams.get('present') === '1'; const axCaps = resolveNodeAxCapabilities(node); const axEnabled = axCaps.enabled && axCaps.allowed.length > 0; + const embeddedAxToken = url.searchParams.get('axToken') ?? ''; + const standaloneAxToken = axEnabled && !embeddedAxToken ? mintStandaloneAxGrant(node.id) : undefined; const surfaceTitle = typeof node.data.title === 'string' && node.data.title.trim() ? node.data.title : node.id; const doc = buildHtmlSurfaceDocument(html, { theme, @@ -1467,7 +1550,8 @@ function handleNodeSurface(pathname: string, url: URL): Response { presentation: present, presentationExitToken: url.searchParams.get('presentToken') ?? undefined, axBridge: axEnabled, - axToken: url.searchParams.get('axToken') ?? undefined, + axToken: embeddedAxToken || undefined, + standaloneAxToken, nodeId: node.id, // Seed the read-side bridge with the current AX state (only for AX surfaces). ...(axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}), @@ -3328,6 +3412,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n return handleIframeProbe(); } + if (url.pathname.startsWith('/api/canvas/surface-ax/')) { + return handleStandaloneAxRequest(req, url); + } + if (url.pathname.startsWith('/api/canvas/surface/') && (req.method === 'GET' || req.method === 'HEAD')) { return handleNodeSurface(url.pathname, url); } diff --git a/tests/unit/server-api.test.ts b/tests/unit/server-api.test.ts index 78eb6895..c8fd0139 100644 --- a/tests/unit/server-api.test.ts +++ b/tests/unit/server-api.test.ts @@ -2620,6 +2620,62 @@ describe('canvas server HTTP API', () => { expect(on).toContain('ax-test'); }); + test('standalone AX surface uses a scoped grant for state and interactions', async () => { + canvasState.addNode({ + id: 'surf-ax-standalone', + type: 'html', + position: { x: 0, y: 0 }, + size: { width: 400, height: 300 }, + zIndex: 1, + collapsed: false, + pinned: false, + data: { html: '
x
', axCapabilities: { enabled: true, allowed: ['ax.work.create'] } }, + }); + + const surface = await fetch(`${baseUrl}/api/canvas/surface/surf-ax-standalone`); + const html = await surface.text(); + expect(html).toContain('data-pmx-canvas-standalone-ax-bridge'); + const token = html.match(/const PMX_AX_TOKEN = "([^"]+)";/)?.[1]; + expect(token).toBeTruthy(); + + const state = await fetch( + `${baseUrl}/api/canvas/surface-ax/surf-ax-standalone/state?token=${encodeURIComponent(token as string)}`, + { headers: { Origin: 'null' } }, + ); + expect(state.status).toBe(200); + expect(state.headers.get('access-control-allow-origin')).toBe('null'); + expect((await state.json()) as { workItems?: unknown[] }).toHaveProperty('workItems'); + + const created = await fetch( + `${baseUrl}/api/canvas/surface-ax/surf-ax-standalone/interaction?token=${encodeURIComponent(token as string)}`, + { + method: 'POST', + headers: { Origin: 'null', 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'ax.work.create', payload: { title: 'Standalone work' } }), + }, + ); + expect(created.status).toBe(200); + expect((await created.json()) as { ok?: boolean }).toMatchObject({ ok: true }); + expect(canvasState.getWorkItems().some((item) => item.title === 'Standalone work')).toBe(true); + + const forbidden = await fetch( + `${baseUrl}/api/canvas/surface-ax/surf-ax-standalone/interaction?token=${encodeURIComponent(token as string)}`, + { + method: 'POST', + headers: { Origin: 'null', 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'ax.steer', payload: { message: 'not allowed' } }), + }, + ); + expect(forbidden.status).toBe(403); + expect((await forbidden.json()) as { code?: string }).toMatchObject({ code: 'not-allowed' }); + + const embedded = await fetch(`${baseUrl}/api/canvas/surface/surf-ax-standalone?axToken=embedded-token`).then( + (response) => response.text(), + ); + expect(embedded).not.toContain('data-pmx-canvas-standalone-ax-bridge'); + expect(embedded).toContain('embedded-token'); + }); + test('surface route falls back to content, 404s when html node is empty', async () => { canvasState.addNode({ id: 'surf-content', From ef610cd6fd733526455a5a922b91e20fb6a0d3e1 Mon Sep 17 00:00:00 2001 From: Benjamin Bisgaard Krogh Date: Thu, 10 Sep 2026 10:01:16 +0200 Subject: [PATCH 2/2] Harden standalone AX grant lifecycle Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/server/server.ts | 8 ++++++++ tests/unit/server-api.test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/server/server.ts b/src/server/server.ts index fc04b143..9b849d27 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1417,6 +1417,12 @@ function validateStandaloneAxGrant(token: string, nodeId: string): boolean { if (grant) standaloneAxGrants.delete(token); return false; } + const node = canvasState.getNode(nodeId); + const capabilities = node ? resolveNodeAxCapabilities(node) : null; + if (node?.type !== 'html' || !capabilities?.enabled || capabilities.allowed.length === 0) { + standaloneAxGrants.delete(token); + return false; + } return true; } @@ -3273,6 +3279,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n if (server) { return typeof server.port === 'number' ? loopbackBaseUrl(server.port) : null; } + standaloneAxGrants.clear(); // An explicit `options.workspaceRoot` wins. Otherwise honor PMX_CANVAS_WORKSPACE_ROOT // (Finding I escape hatch) before falling back to the launch cwd, so a host that @@ -3516,6 +3523,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n } export function stopCanvasServer(): void { + standaloneAxGrants.clear(); stopGateTtlSweeper(); agentPresence.reset(); humanPresence.reset(); diff --git a/tests/unit/server-api.test.ts b/tests/unit/server-api.test.ts index c8fd0139..e0289dd3 100644 --- a/tests/unit/server-api.test.ts +++ b/tests/unit/server-api.test.ts @@ -2669,6 +2669,39 @@ describe('canvas server HTTP API', () => { expect(forbidden.status).toBe(403); expect((await forbidden.json()) as { code?: string }).toMatchObject({ code: 'not-allowed' }); + canvasState.updateNode('surf-ax-standalone', { + data: { + ...canvasState.getNode('surf-ax-standalone')?.data, + axCapabilities: { enabled: false, allowed: ['ax.work.create'] }, + }, + }); + const revoked = await fetch( + `${baseUrl}/api/canvas/surface-ax/surf-ax-standalone/state?token=${encodeURIComponent(token as string)}`, + { headers: { Origin: 'null' } }, + ); + expect(revoked.status).toBe(403); + expect((await revoked.json()) as { code?: string }).toMatchObject({ code: 'invalid-grant' }); + + canvasState.updateNode('surf-ax-standalone', { + data: { + ...canvasState.getNode('surf-ax-standalone')?.data, + axCapabilities: { enabled: true, allowed: ['ax.work.create'] }, + }, + }); + const restartHtml = await (await fetch(`${baseUrl}/api/canvas/surface/surf-ax-standalone`)).text(); + const restartToken = restartHtml.match(/const PMX_AX_TOKEN = "([^"]+)";/)?.[1]; + expect(restartToken).toBeTruthy(); + stopCanvasServer(); + const restarted = startCanvasServer({ workspaceRoot, port: 0 }); + expect(restarted).toBeTruthy(); + baseUrl = restarted!; + const expiredOnRestart = await fetch( + `${baseUrl}/api/canvas/surface-ax/surf-ax-standalone/state?token=${encodeURIComponent(restartToken as string)}`, + { headers: { Origin: 'null' } }, + ); + expect(expiredOnRestart.status).toBe(403); + expect((await expiredOnRestart.json()) as { code?: string }).toMatchObject({ code: 'invalid-grant' }); + const embedded = await fetch(`${baseUrl}/api/canvas/surface/surf-ax-standalone?axToken=embedded-token`).then( (response) => response.text(), );