pmx-canvas 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +129 -0
- package/Readme.md +325 -68
- package/dist/types/server/canvas-schema.d.ts +2 -0
- package/dist/types/server/image-source.d.ts +3 -0
- package/dist/types/server/index.d.ts +1 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +179 -12
- package/src/cli/agent.ts +75 -7
- package/src/cli/index.ts +3 -1
- package/src/json-render/server.ts +24 -0
- package/src/mcp/server.ts +15 -5
- package/src/server/canvas-operations.ts +3 -11
- package/src/server/canvas-schema.ts +53 -1
- package/src/server/image-source.ts +206 -0
- package/src/server/server.ts +17 -5
|
@@ -22,6 +22,7 @@ export interface CanvasCreateTypeSchema {
|
|
|
22
22
|
kind: 'node' | 'virtual-node';
|
|
23
23
|
description: string;
|
|
24
24
|
endpoint: string;
|
|
25
|
+
mcpTool?: string;
|
|
25
26
|
fields: CanvasCreateField[];
|
|
26
27
|
example: Record<string, unknown>;
|
|
27
28
|
notes?: string[];
|
|
@@ -63,6 +64,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
63
64
|
kind: 'node',
|
|
64
65
|
description: 'Freeform markdown note.',
|
|
65
66
|
endpoint: '/api/canvas/node',
|
|
67
|
+
mcpTool: 'canvas_add_node',
|
|
66
68
|
fields: [
|
|
67
69
|
{ name: 'title', type: 'string', required: false, description: 'Optional node title.' },
|
|
68
70
|
{ name: 'content', type: 'string', required: false, description: 'Markdown body.' },
|
|
@@ -85,6 +87,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
85
87
|
kind: 'node',
|
|
86
88
|
description: 'Compact status indicator.',
|
|
87
89
|
endpoint: '/api/canvas/node',
|
|
90
|
+
mcpTool: 'canvas_add_node',
|
|
88
91
|
fields: [
|
|
89
92
|
{ name: 'title', type: 'string', required: false, description: 'Status label.' },
|
|
90
93
|
{ name: 'content', type: 'string', required: false, description: 'Rendered status text.' },
|
|
@@ -100,6 +103,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
100
103
|
kind: 'node',
|
|
101
104
|
description: 'Agent context card container.',
|
|
102
105
|
endpoint: '/api/canvas/node',
|
|
106
|
+
mcpTool: 'canvas_add_node',
|
|
103
107
|
fields: [
|
|
104
108
|
{ name: 'title', type: 'string', required: false, description: 'Optional title override.' },
|
|
105
109
|
{ name: 'content', type: 'string', required: false, description: 'Optional context body.' },
|
|
@@ -115,6 +119,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
115
119
|
kind: 'node',
|
|
116
120
|
description: 'Structured ledger/log node.',
|
|
117
121
|
endpoint: '/api/canvas/node',
|
|
122
|
+
mcpTool: 'canvas_add_node',
|
|
118
123
|
fields: [
|
|
119
124
|
{ name: 'title', type: 'string', required: false, description: 'Optional title.' },
|
|
120
125
|
{ name: 'content', type: 'string', required: false, description: 'Ledger body text.' },
|
|
@@ -130,6 +135,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
130
135
|
kind: 'node',
|
|
131
136
|
description: 'Execution trace viewer.',
|
|
132
137
|
endpoint: '/api/canvas/node',
|
|
138
|
+
mcpTool: 'canvas_add_node',
|
|
133
139
|
fields: [
|
|
134
140
|
{ name: 'title', type: 'string', required: false, description: 'Optional title.' },
|
|
135
141
|
{ name: 'content', type: 'string', required: false, description: 'Trace summary.' },
|
|
@@ -145,6 +151,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
145
151
|
kind: 'node',
|
|
146
152
|
description: 'Workspace file viewer.',
|
|
147
153
|
endpoint: '/api/canvas/node',
|
|
154
|
+
mcpTool: 'canvas_add_node',
|
|
148
155
|
fields: [
|
|
149
156
|
{ name: 'content', type: 'string', required: true, description: 'Workspace-relative or absolute file path.' },
|
|
150
157
|
{ name: 'title', type: 'string', required: false, description: 'Optional title override.' },
|
|
@@ -162,6 +169,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
162
169
|
kind: 'node',
|
|
163
170
|
description: 'Image node backed by a path, URL, or data URI.',
|
|
164
171
|
endpoint: '/api/canvas/node',
|
|
172
|
+
mcpTool: 'canvas_add_node',
|
|
165
173
|
fields: [
|
|
166
174
|
{ name: 'content', type: 'string', required: true, description: 'Image path, URL, or data URI.' },
|
|
167
175
|
{ name: 'title', type: 'string', required: false, description: 'Optional title override.' },
|
|
@@ -187,6 +195,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
187
195
|
kind: 'node',
|
|
188
196
|
description: 'Persisted webpage snapshot with server-side fetch and refresh.',
|
|
189
197
|
endpoint: '/api/canvas/node',
|
|
198
|
+
mcpTool: 'canvas_add_node',
|
|
190
199
|
fields: [
|
|
191
200
|
{ name: 'url', type: 'string', required: true, description: 'HTTP(S) URL to fetch and cache.', aliases: ['content'] },
|
|
192
201
|
{ name: 'title', type: 'string', required: false, description: 'Optional title override.' },
|
|
@@ -210,6 +219,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
210
219
|
kind: 'node',
|
|
211
220
|
description: 'Hosted iframe/app node.',
|
|
212
221
|
endpoint: '/api/canvas/node',
|
|
222
|
+
mcpTool: 'canvas_open_mcp_app',
|
|
213
223
|
fields: [
|
|
214
224
|
{ name: 'title', type: 'string', required: false, description: 'App title.' },
|
|
215
225
|
{ name: 'content', type: 'string', required: false, description: 'Optional inline content.' },
|
|
@@ -223,11 +233,35 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
223
233
|
'Tool-backed MCP app nodes and hosted artifact nodes persist `data.provenance` when the server can infer a reopen or rehydrate path.',
|
|
224
234
|
],
|
|
225
235
|
},
|
|
236
|
+
{
|
|
237
|
+
type: 'external-app',
|
|
238
|
+
kind: 'virtual-node',
|
|
239
|
+
description: 'Tool-backed hosted app opened from an external MCP server, such as Excalidraw.',
|
|
240
|
+
endpoint: '/api/canvas/mcp-app/open',
|
|
241
|
+
mcpTool: 'canvas_open_mcp_app',
|
|
242
|
+
fields: [
|
|
243
|
+
{ name: 'toolName', type: 'string', required: true, description: 'Tool name on the external MCP server.' },
|
|
244
|
+
{ name: 'transport', type: '{ type: "stdio", command, args? } | { type: "http", url, headers? }', required: true, description: 'External MCP transport definition.' },
|
|
245
|
+
{ name: 'toolArguments', type: 'record<string, unknown>', required: false, description: 'Arguments passed to the external MCP tool.' },
|
|
246
|
+
{ name: 'title', type: 'string', required: false, description: 'Optional canvas node title override.' },
|
|
247
|
+
],
|
|
248
|
+
example: {
|
|
249
|
+
toolName: 'create_view',
|
|
250
|
+
transport: { type: 'http', url: 'https://mcp.excalidraw.com/mcp' },
|
|
251
|
+
toolArguments: { elements: '[]' },
|
|
252
|
+
title: 'Excalidraw Diagram',
|
|
253
|
+
},
|
|
254
|
+
notes: [
|
|
255
|
+
'For Excalidraw specifically, prefer canvas_add_diagram because it fills in the built-in transport, toolName, and checkpoint wiring.',
|
|
256
|
+
'The CLI convenience command `external-app add --kind excalidraw` maps to this built-in Excalidraw preset; MCP canvas_open_mcp_app is the lower-level transport form.',
|
|
257
|
+
],
|
|
258
|
+
},
|
|
226
259
|
{
|
|
227
260
|
type: 'group',
|
|
228
261
|
kind: 'node',
|
|
229
262
|
description: 'Canvas group frame.',
|
|
230
263
|
endpoint: '/api/canvas/group',
|
|
264
|
+
mcpTool: 'canvas_create_group',
|
|
231
265
|
fields: [
|
|
232
266
|
{ name: 'title', type: 'string', required: false, description: 'Group title.' },
|
|
233
267
|
{ name: 'childIds', type: 'string[]', required: false, description: 'Initial child node IDs.' },
|
|
@@ -244,6 +278,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
244
278
|
kind: 'virtual-node',
|
|
245
279
|
description: 'Native structured UI panel rendered from a validated json-render spec.',
|
|
246
280
|
endpoint: '/api/canvas/json-render',
|
|
281
|
+
mcpTool: 'canvas_add_json_render_node',
|
|
247
282
|
fields: [
|
|
248
283
|
{ name: 'title', type: 'string', required: true, description: 'Rendered node title.' },
|
|
249
284
|
{ name: 'spec', type: 'JsonRenderSpec', required: true, description: 'Complete json-render spec.' },
|
|
@@ -276,6 +311,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
276
311
|
kind: 'virtual-node',
|
|
277
312
|
description: 'Native chart node backed by the json-render chart catalog.',
|
|
278
313
|
endpoint: '/api/canvas/graph',
|
|
314
|
+
mcpTool: 'canvas_add_graph_node',
|
|
279
315
|
fields: [
|
|
280
316
|
{
|
|
281
317
|
name: 'graphType',
|
|
@@ -325,6 +361,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
325
361
|
kind: 'virtual-node',
|
|
326
362
|
description: 'Bundled single-file HTML artifact that can open as an embedded canvas node.',
|
|
327
363
|
endpoint: '/api/canvas/web-artifact',
|
|
364
|
+
mcpTool: 'canvas_build_web_artifact',
|
|
328
365
|
fields: [
|
|
329
366
|
{ name: 'title', type: 'string', required: true, description: 'Artifact title used for default paths.' },
|
|
330
367
|
{ name: 'appTsx', type: 'string', required: true, description: 'Contents for src/App.tsx. The CLI also accepts piped contents via --stdin.', aliases: ['app-file', 'app-tsx'] },
|
|
@@ -349,6 +386,16 @@ function clone<T>(value: T): T {
|
|
|
349
386
|
return JSON.parse(JSON.stringify(value)) as T;
|
|
350
387
|
}
|
|
351
388
|
|
|
389
|
+
function buildMcpNodeTypeRouting(nodeTypes: CanvasCreateTypeSchema[]): Record<string, string> {
|
|
390
|
+
const routing: Record<string, string> = {};
|
|
391
|
+
for (const entry of nodeTypes) {
|
|
392
|
+
if (typeof entry.mcpTool === 'string') {
|
|
393
|
+
routing[entry.type] = entry.mcpTool;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return routing;
|
|
397
|
+
}
|
|
398
|
+
|
|
352
399
|
export function describeCanvasSchema(): {
|
|
353
400
|
ok: true;
|
|
354
401
|
source: 'running-server';
|
|
@@ -364,13 +411,15 @@ export function describeCanvasSchema(): {
|
|
|
364
411
|
mcp: {
|
|
365
412
|
tools: string[];
|
|
366
413
|
resources: string[];
|
|
414
|
+
nodeTypeRouting: Record<string, string>;
|
|
367
415
|
};
|
|
368
416
|
} {
|
|
417
|
+
const nodeTypes = clone(CANVAS_CREATE_TYPES);
|
|
369
418
|
return {
|
|
370
419
|
ok: true,
|
|
371
420
|
source: 'running-server',
|
|
372
421
|
version: readPackageVersion(),
|
|
373
|
-
nodeTypes
|
|
422
|
+
nodeTypes,
|
|
374
423
|
jsonRender: {
|
|
375
424
|
rootShape: {
|
|
376
425
|
root: 'string',
|
|
@@ -388,10 +437,13 @@ export function describeCanvasSchema(): {
|
|
|
388
437
|
'canvas_add_json_render_node',
|
|
389
438
|
'canvas_add_graph_node',
|
|
390
439
|
'canvas_build_web_artifact',
|
|
440
|
+
'canvas_open_mcp_app',
|
|
441
|
+
'canvas_create_group',
|
|
391
442
|
'canvas_describe_schema',
|
|
392
443
|
'canvas_validate_spec',
|
|
393
444
|
],
|
|
394
445
|
resources: ['canvas://schema'],
|
|
446
|
+
nodeTypeRouting: buildMcpNodeTypeRouting(nodeTypes),
|
|
395
447
|
},
|
|
396
448
|
};
|
|
397
449
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { IMAGE_MIME_MAP } from './canvas-state.js';
|
|
5
|
+
|
|
6
|
+
const IMAGE_HEADER_BYTES = 512;
|
|
7
|
+
const IMAGE_HEADER_READ_TIMEOUT_MS = 5000;
|
|
8
|
+
// Set by `chflags hidden`, iCloud Drive, OneDrive, etc. on macOS — the
|
|
9
|
+
// metadata exists but the file content has not been downloaded locally.
|
|
10
|
+
const MACOS_DATALESS_FLAG = 0x40000000;
|
|
11
|
+
// Per macOS `man stat -f %Xf`, this bit is also set on iCloud Documents
|
|
12
|
+
// & Files for content-not-yet-downloaded entries on newer OS releases.
|
|
13
|
+
const MACOS_BSD_NODUMP_FLAG = 0x00000001;
|
|
14
|
+
|
|
15
|
+
function fileName(path: string): string {
|
|
16
|
+
return basename(path) || path;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readMacosFileFlags(path: string): number | null {
|
|
20
|
+
if (process.platform !== 'darwin') return null;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const raw = execFileSync('/usr/bin/stat', ['-f', '%Xf', path], {
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
26
|
+
timeout: 1000,
|
|
27
|
+
}).trim();
|
|
28
|
+
return raw.length > 0 ? Number.parseInt(raw, 16) : null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isMacosCloudPlaceholder(path: string): boolean {
|
|
35
|
+
const flags = readMacosFileFlags(path);
|
|
36
|
+
if (flags === null) return false;
|
|
37
|
+
// Both flags can indicate iCloud-on-demand status depending on macOS
|
|
38
|
+
// release; treat either as a placeholder.
|
|
39
|
+
return (flags & MACOS_DATALESS_FLAG) !== 0 || (flags & MACOS_BSD_NODUMP_FLAG) !== 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assertNotCloudPlaceholder(path: string): void {
|
|
43
|
+
if (isMacosCloudPlaceholder(path)) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Invalid image node: "${fileName(path)}" appears to be a cloud-on-demand placeholder. ` +
|
|
46
|
+
'Ensure the file is downloaded locally before adding it as an image.',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readHeaderWithDirectFs(path: string, size: number): Buffer {
|
|
52
|
+
const length = Math.min(IMAGE_HEADER_BYTES, size);
|
|
53
|
+
const buffer = Buffer.alloc(length);
|
|
54
|
+
const fd = openSync(path, 'r');
|
|
55
|
+
try {
|
|
56
|
+
const bytesRead = readSync(fd, buffer, 0, length, 0);
|
|
57
|
+
return buffer.subarray(0, bytesRead);
|
|
58
|
+
} finally {
|
|
59
|
+
closeSync(fd);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ProcessSpawnError {
|
|
64
|
+
code?: string;
|
|
65
|
+
signal?: NodeJS.Signals;
|
|
66
|
+
status?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isProcessSpawnError(value: unknown): value is ProcessSpawnError {
|
|
70
|
+
return value !== null && typeof value === 'object';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readHeaderWithDd(path: string): Buffer {
|
|
74
|
+
return execFileSync('/bin/dd', [`if=${path}`, `bs=${IMAGE_HEADER_BYTES}`, 'count=1'], {
|
|
75
|
+
encoding: 'buffer',
|
|
76
|
+
maxBuffer: IMAGE_HEADER_BYTES,
|
|
77
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
78
|
+
timeout: IMAGE_HEADER_READ_TIMEOUT_MS,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readHeaderWithTimeout(path: string, size: number): Buffer {
|
|
83
|
+
// Direct fs read is the fast path on every platform — no fork, no shell,
|
|
84
|
+
// no timeout required because the kernel either returns bytes immediately
|
|
85
|
+
// or fails synchronously. The `dd` escape hatch only matters for macOS
|
|
86
|
+
// cloud-on-demand placeholders, which `assertNotCloudPlaceholder` rejected
|
|
87
|
+
// at the call site, so this path is safe.
|
|
88
|
+
try {
|
|
89
|
+
return readHeaderWithDirectFs(path, size);
|
|
90
|
+
} catch (directError) {
|
|
91
|
+
// On macOS, fall through to `/bin/dd` so a kernel-level stall on a path
|
|
92
|
+
// we did not flag as a placeholder (e.g. an unmounted SMB share that
|
|
93
|
+
// still satisfies `existsSync`) cannot wedge a Bun fiber.
|
|
94
|
+
if (process.platform !== 'darwin' || !existsSync('/bin/dd')) {
|
|
95
|
+
throw directError;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return readHeaderWithDd(path);
|
|
99
|
+
} catch (ddError) {
|
|
100
|
+
const reason = isProcessSpawnError(ddError) ? ddError : null;
|
|
101
|
+
const timedOut = reason?.signal === 'SIGTERM' || reason?.code === 'ETIMEDOUT';
|
|
102
|
+
if (timedOut) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Invalid image node: could not read image header for "${fileName(path)}" within ${IMAGE_HEADER_READ_TIMEOUT_MS}ms. ` +
|
|
105
|
+
'If this file is stored in OneDrive, iCloud Drive, or another cloud-on-demand provider, download it locally first.',
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const detail = ddError instanceof Error ? ddError.message : String(ddError);
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Invalid image node: could not read "${fileName(path)}" — ${detail}.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function hasAscii(buffer: Buffer, offset: number, value: string): boolean {
|
|
117
|
+
return buffer.subarray(offset, offset + value.length).toString('ascii') === value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function detectImageMimeType(header: Buffer): string | null {
|
|
121
|
+
if (
|
|
122
|
+
header.length >= 8 &&
|
|
123
|
+
header[0] === 0x89 &&
|
|
124
|
+
hasAscii(header, 1, 'PNG') &&
|
|
125
|
+
header[4] === 0x0d &&
|
|
126
|
+
header[5] === 0x0a &&
|
|
127
|
+
header[6] === 0x1a &&
|
|
128
|
+
header[7] === 0x0a
|
|
129
|
+
) {
|
|
130
|
+
return 'image/png';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (header.length >= 3 && header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) {
|
|
134
|
+
return 'image/jpeg';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (header.length >= 6 && (hasAscii(header, 0, 'GIF87a') || hasAscii(header, 0, 'GIF89a'))) {
|
|
138
|
+
return 'image/gif';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (header.length >= 12 && hasAscii(header, 0, 'RIFF') && hasAscii(header, 8, 'WEBP')) {
|
|
142
|
+
return 'image/webp';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (header.length >= 2 && hasAscii(header, 0, 'BM')) {
|
|
146
|
+
return 'image/bmp';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (
|
|
150
|
+
header.length >= 4 &&
|
|
151
|
+
header[0] === 0x00 &&
|
|
152
|
+
header[1] === 0x00 &&
|
|
153
|
+
(header[2] === 0x01 || header[2] === 0x02) &&
|
|
154
|
+
header[3] === 0x00
|
|
155
|
+
) {
|
|
156
|
+
return 'image/x-icon';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (header.length >= 12 && hasAscii(header, 4, 'ftyp')) {
|
|
160
|
+
const brandArea = header.subarray(8, Math.min(header.length, 32)).toString('ascii');
|
|
161
|
+
if (brandArea.includes('avif') || brandArea.includes('avis')) return 'image/avif';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const text = header.toString('utf8').replace(/^\uFEFF/, '').trimStart().toLowerCase();
|
|
165
|
+
if (text.startsWith('<svg') || (text.startsWith('<?xml') && text.includes('<svg'))) {
|
|
166
|
+
return 'image/svg+xml';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function validateLocalImageFile(path: string): { mimeType: string } {
|
|
173
|
+
const name = fileName(path);
|
|
174
|
+
if (!existsSync(path)) {
|
|
175
|
+
throw new Error(`Invalid image node: "${name}" does not exist.`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const stat = statSync(path);
|
|
179
|
+
if (!stat.isFile()) {
|
|
180
|
+
throw new Error(`Invalid image node: "${name}" is not a regular file.`);
|
|
181
|
+
}
|
|
182
|
+
if (stat.size <= 0) {
|
|
183
|
+
throw new Error(`Invalid image node: "${name}" is empty.`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
|
187
|
+
if (!IMAGE_MIME_MAP[ext]) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`Invalid image node: "${name}" has unsupported extension ".${ext}". ` +
|
|
190
|
+
`Accepted: ${Object.keys(IMAGE_MIME_MAP).join(', ')}. ` +
|
|
191
|
+
'For non-image files use type="file" (live viewer) or type="webpage" (URL) instead.',
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
assertNotCloudPlaceholder(path);
|
|
196
|
+
const header = readHeaderWithTimeout(path, stat.size);
|
|
197
|
+
const mimeType = detectImageMimeType(header);
|
|
198
|
+
if (!mimeType) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Invalid image node: "${name}" is not a recognized image file. ` +
|
|
201
|
+
'Expected PNG, JPEG, GIF, SVG, WebP, BMP, ICO, or AVIF image bytes.',
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { mimeType };
|
|
206
|
+
}
|
package/src/server/server.ts
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
import { spawnSync } from 'node:child_process';
|
|
38
38
|
import { existsSync, readFileSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
39
|
+
import { readFile } from 'node:fs/promises';
|
|
39
40
|
import { basename, extname, join, relative, resolve } from 'node:path';
|
|
40
41
|
import * as marked from 'marked';
|
|
41
42
|
import type {
|
|
@@ -67,6 +68,7 @@ import { diffLayouts, formatDiff, mutationHistory } from './mutation-history.js'
|
|
|
67
68
|
import { buildCanvasSummary, serializeCanvasLayout, serializeCanvasNode } from './canvas-serialization.js';
|
|
68
69
|
import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
|
|
69
70
|
import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
|
|
71
|
+
import { validateLocalImageFile } from './image-source.js';
|
|
70
72
|
import {
|
|
71
73
|
addCanvasNode,
|
|
72
74
|
addCanvasEdge,
|
|
@@ -1091,7 +1093,7 @@ async function handleCanvasViewport(req: Request): Promise<Response> {
|
|
|
1091
1093
|
}
|
|
1092
1094
|
|
|
1093
1095
|
// ── Serve image file for image nodes ─────────────────────────
|
|
1094
|
-
function handleCanvasImage(pathname: string): Response {
|
|
1096
|
+
async function handleCanvasImage(pathname: string): Promise<Response> {
|
|
1095
1097
|
const nodeId = pathname.replace('/api/canvas/image/', '');
|
|
1096
1098
|
const node = canvasState.getNode(nodeId);
|
|
1097
1099
|
if (!node || node.type !== 'image') {
|
|
@@ -1105,9 +1107,13 @@ function handleCanvasImage(pathname: string): Response {
|
|
|
1105
1107
|
if (!existsSync(safePath)) {
|
|
1106
1108
|
return responseText('Image file not found', 404);
|
|
1107
1109
|
}
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1110
|
+
let contentType: string;
|
|
1111
|
+
try {
|
|
1112
|
+
contentType = validateLocalImageFile(safePath).mimeType;
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
return responseText(error instanceof Error ? error.message : 'Invalid image file', 400);
|
|
1115
|
+
}
|
|
1116
|
+
const data = await readFile(safePath);
|
|
1111
1117
|
return new Response(data, {
|
|
1112
1118
|
headers: {
|
|
1113
1119
|
'Content-Type': contentType,
|
|
@@ -1495,6 +1501,12 @@ async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
|
|
|
1495
1501
|
bytes: result.fileSize,
|
|
1496
1502
|
projectPath: result.projectPath,
|
|
1497
1503
|
openedInCanvas: result.openedInCanvas,
|
|
1504
|
+
// `id` is the canvas node id alias used by every other add-style
|
|
1505
|
+
// response. It is only present when a canvas node was actually
|
|
1506
|
+
// created (i.e. openInCanvas was not explicitly disabled). When
|
|
1507
|
+
// there is no canvas node, the alias is intentionally omitted so
|
|
1508
|
+
// consumers can `'id' in response` to detect the build-only case.
|
|
1509
|
+
...(typeof result.nodeId === 'string' ? { id: result.nodeId } : {}),
|
|
1498
1510
|
nodeId: result.nodeId,
|
|
1499
1511
|
url: result.url,
|
|
1500
1512
|
metadata: result.metadata,
|
|
@@ -3773,7 +3785,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3773
3785
|
}
|
|
3774
3786
|
|
|
3775
3787
|
if (url.pathname.startsWith('/api/canvas/image/') && req.method === 'GET') {
|
|
3776
|
-
return handleCanvasImage(url.pathname);
|
|
3788
|
+
return await handleCanvasImage(url.pathname);
|
|
3777
3789
|
}
|
|
3778
3790
|
|
|
3779
3791
|
if (url.pathname === '/api/canvas/edge' && req.method === 'POST') {
|