Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions skills/pmx-canvas/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions skills/pmx-canvas/references/ax-html-control-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
64 changes: 63 additions & 1 deletion src/server/html-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,62 @@ export function buildAxBridge(axToken: string, nodeId: string): string {
</script>`;
}

/**
* 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 `<script data-pmx-canvas-standalone-ax-bridge>
(function () {
const PMX_AX_TOKEN = ${token};
const PMX_AX_NODE_ID = ${node};
const base = '/api/canvas/surface-ax/' + encodeURIComponent(PMX_AX_NODE_ID);
const ackListeners = [];
let lastState = '';
window.PMX_AX = window.PMX_AX || {};
window.PMX_AX.emit = async function (type, payload) {
let result;
try {
const response = await fetch(base + '/interaction?token=' + encodeURIComponent(PMX_AX_TOKEN), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: String(type), payload: payload && typeof payload === 'object' ? payload : {} }),
});
result = await response.json();
} catch (error) {
result = { ok: false, status: 503, code: 'standalone-bridge-failed', error: String(error) };
}
for (let i = 0; i < ackListeners.length; i += 1) {
try { ackListeners[i](result, { type: String(type), payload: payload || {} }); } catch (e) {}
}
try { window.dispatchEvent(new CustomEvent('pmx-ax-ack', { detail: { result: result, interaction: { type: String(type), payload: payload || {} } } })); } catch (e) {}
return result;
};
window.PMX_AX.on = function (eventType, cb) {
if (eventType === 'ack' && typeof cb === 'function') ackListeners.push(cb);
};
async function refresh() {
try {
const response = await fetch(base + '/state?token=' + encodeURIComponent(PMX_AX_TOKEN), { cache: 'no-store' });
if (!response.ok) return;
const state = await response.json();
const serialized = JSON.stringify(state);
if (serialized === lastState) return;
lastState = serialized;
window.PMX_AX.state = state;
try { window.dispatchEvent(new CustomEvent('pmx-ax-update', { detail: state })); } catch (e) {}
} catch (e) {}
}
void refresh();
window.setInterval(refresh, 1500);
})();
</script>`;
}

/**
* 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
Expand Down Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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). `</` is escaped so a work-item
// title containing "</script>" can't break out of the inline script.
const axStateBridge = options.axBridge
Expand Down
100 changes: 98 additions & 2 deletions src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1398,6 +1398,93 @@ 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<string, { nodeId: string; expiresAt: number }>();

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;
}
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;
}

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<Response> {
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 {
Expand Down Expand Up @@ -1459,6 +1546,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,
Expand All @@ -1467,7 +1556,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() } : {}),
Expand Down Expand Up @@ -3189,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
Expand Down Expand Up @@ -3328,6 +3419,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);
}
Expand Down Expand Up @@ -3428,6 +3523,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
}

export function stopCanvasServer(): void {
standaloneAxGrants.clear();
stopGateTtlSweeper();
agentPresence.reset();
humanPresence.reset();
Expand Down
89 changes: 89 additions & 0 deletions tests/unit/server-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2620,6 +2620,95 @@ 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: '<main>x</main>', 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' });

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(),
);
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',
Expand Down