pmx-canvas 0.1.4 → 0.1.5

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 CHANGED
@@ -3,6 +3,62 @@
3
3
  All notable changes to `pmx-canvas` are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.1.5] - 2026-04-26
7
+
8
+ Image-validation hardening + CLI ergonomics. The boundary where untrusted
9
+ file paths become canvas state now validates magic bytes, and common CLI
10
+ typos produce one-line suggestions instead of help-block dumps.
11
+
12
+ ### Added
13
+
14
+ - **Magic-byte validation for local image nodes.** PNG / JPEG / GIF / SVG /
15
+ WebP / BMP / ICO / AVIF headers are sniffed before a file becomes an
16
+ `image` node. A file renamed `screenshot.png` containing PowerPoint XML
17
+ is now rejected with a clear error before it reaches the renderer.
18
+ - **macOS cloud-on-demand placeholder detection.** Files in iCloud Drive,
19
+ OneDrive, etc. that are not yet downloaded locally are detected via
20
+ `stat -f %Xf` flags and rejected with a hint to download them first —
21
+ no more silent freezes when an iCloud-only file is dropped on the canvas.
22
+ - **`/bin/dd` escape hatch with a 5s timeout** for macOS-only paths where
23
+ the direct fs read could hang on an unresponsive volume (e.g. an
24
+ unmounted SMB share that still satisfies `existsSync`). Distinguishes
25
+ timeout (`SIGTERM` / `ETIMEDOUT`) from generic spawn failures so the
26
+ cloud-storage hint isn't shown for unrelated errors.
27
+ - **CLI typo hints for resource subcommands.**
28
+ - `pmx-canvas node delete <id>` and `pmx-canvas node rm <id>` exit 1 with
29
+ `Did you mean: pmx-canvas node remove?`.
30
+ - `pmx-canvas edge delete <id>` and `pmx-canvas edge rm <id>` get the
31
+ same treatment.
32
+ - `pmx-canvas node pin <id>` redirects to the top-level
33
+ `pmx-canvas pin <id>` command.
34
+
35
+ ### Changed
36
+
37
+ - **`GET /api/canvas/image/:id` is now async** (`fs/promises.readFile`) and
38
+ validates content before serving — returns **400** on invalid image bytes
39
+ instead of 200 with `application/octet-stream`.
40
+ - **Bare `pmx-canvas node` (no subcommand)** now exits 1 with structured
41
+ JSON instead of printing the resource help block. Use
42
+ `pmx-canvas node --help` for the listing.
43
+
44
+ ### Internal
45
+
46
+ - New module `src/server/image-source.ts` extracts and extends image
47
+ validation from `canvas-operations.ts`. Same error contract; richer
48
+ checks. The MCP and HTTP layers both flow through `addCanvasNode`, so
49
+ CLAUDE.md rule #5 (four-layer parity) is preserved without touching
50
+ the SDK or MCP server.
51
+ - Direct fs read is the fast path on every platform (no fork, no shell);
52
+ `dd` is only consulted on macOS as a fallback when direct read fails on
53
+ a path that wasn't flagged as a placeholder.
54
+ - Real magic-byte fixtures in `tests/unit/canvas-operations.test.ts` (was:
55
+ `*.png` extension smoke tests). New HTTP coverage in
56
+ `tests/unit/server-api.test.ts` for valid / invalid / missing image
57
+ paths. New CLI coverage for `node delete`, `node pin`, `edge delete`,
58
+ `edge rm` typo hints.
59
+
60
+ [0.1.5]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.5
61
+
6
62
  ## [0.1.4] - 2026-04-26
7
63
 
8
64
  Graph/CLI ergonomics + canvas-node taxonomy hardening. Three threads:
@@ -0,0 +1,3 @@
1
+ export declare function validateLocalImageFile(path: string): {
2
+ mimeType: string;
3
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
package/src/cli/agent.ts CHANGED
@@ -873,6 +873,21 @@ function filterJsonRenderSchemaView(
873
873
  // ── Commands ─────────────────────────────────────────────────
874
874
 
875
875
  const COMMANDS: Record<string, { run: (args: string[]) => Promise<void>; help: string; examples: string[] }> = {};
876
+ const RESOURCE_COMMAND_ALIASES: Record<string, Record<string, string>> = {
877
+ node: {
878
+ delete: 'remove',
879
+ rm: 'remove',
880
+ },
881
+ edge: {
882
+ delete: 'remove',
883
+ rm: 'remove',
884
+ },
885
+ };
886
+ const RESOURCE_SUBCOMMAND_HINTS: Record<string, Record<string, string>> = {
887
+ node: {
888
+ pin: 'Use the top-level pin command instead: pmx-canvas pin <node-id>',
889
+ },
890
+ };
876
891
 
877
892
  function cmd(
878
893
  name: string,
@@ -2225,12 +2240,32 @@ export async function runAgentCli(args: string[]): Promise<void> {
2225
2240
  // Unknown command — show help for the resource if it exists
2226
2241
  const resourceCommands = Object.keys(COMMANDS).filter((k) => k.startsWith(oneWord + ' '));
2227
2242
  if (resourceCommands.length > 0) {
2228
- console.log(`\nAvailable "${oneWord}" commands:\n`);
2229
- for (const k of resourceCommands) {
2230
- console.log(` pmx-canvas ${k.padEnd(20)} ${COMMANDS[k].help}`);
2243
+ if (args[1] === '--help' || args[1] === '-h') {
2244
+ console.log(`\nAvailable "${oneWord}" commands:\n`);
2245
+ for (const k of resourceCommands) {
2246
+ console.log(` pmx-canvas ${k.padEnd(20)} ${COMMANDS[k].help}`);
2247
+ }
2248
+ console.log('\nRun any command with --help for details.\n');
2249
+ return;
2231
2250
  }
2232
- console.log('\nRun any command with --help for details.\n');
2233
- return;
2251
+ const subcommand = args[1];
2252
+ const suggestion = subcommand ? RESOURCE_COMMAND_ALIASES[oneWord]?.[subcommand] : undefined;
2253
+ const extraHint = subcommand ? RESOURCE_SUBCOMMAND_HINTS[oneWord]?.[subcommand] : undefined;
2254
+ const available = resourceCommands
2255
+ .map((k) => k.slice(oneWord.length + 1))
2256
+ .sort()
2257
+ .join(', ');
2258
+ const hints = [
2259
+ suggestion ? `Did you mean: pmx-canvas ${oneWord} ${suggestion}?` : undefined,
2260
+ extraHint,
2261
+ `Available subcommands: ${available}`,
2262
+ ].filter((hint): hint is string => typeof hint === 'string');
2263
+ die(
2264
+ subcommand
2265
+ ? `Unknown ${oneWord} subcommand: "${subcommand}".`
2266
+ : `Missing ${oneWord} subcommand.`,
2267
+ hints.join(' '),
2268
+ );
2234
2269
  }
2235
2270
 
2236
2271
  die(`Unknown command: ${oneWord}`, 'Run: pmx-canvas --help');
@@ -4,7 +4,6 @@ import { recomputeCodeGraph } from './code-graph.js';
4
4
  import {
5
5
  canvasState,
6
6
  type CanvasEdge,
7
- IMAGE_MIME_MAP,
8
7
  type CanvasNodeState,
9
8
  type CanvasNodeUpdate,
10
9
  type CanvasSnapshot,
@@ -36,6 +35,7 @@ import {
36
35
  getWebpageFetchErrorDetails,
37
36
  normalizeWebpageUrl,
38
37
  } from './webpage-node.js';
38
+ import { validateLocalImageFile } from './image-source.js';
39
39
  import { buildExcalidrawRestoreCheckpointToolInput, ensureExcalidrawCheckpointId, isExcalidrawCreateView } from './diagram-presets.js';
40
40
 
41
41
  export type CanvasArrangeMode = 'grid' | 'column' | 'flow';
@@ -435,22 +435,14 @@ function buildImageNodeData(input: CanvasAddNodeInput): Record<string, unknown>
435
435
 
436
436
  if (!isDataUri && !isUrl && src) {
437
437
  const resolved = resolve(src);
438
- const ext = resolved.split('.').pop()?.toLowerCase() ?? '';
439
438
  const fileName = resolved.split('/').pop() ?? src;
440
- const mime = IMAGE_MIME_MAP[ext];
441
- if (!mime) {
442
- throw new Error(
443
- `Invalid image node: "${fileName}" has unsupported extension ".${ext}". ` +
444
- `Accepted: ${Object.keys(IMAGE_MIME_MAP).join(', ')}. ` +
445
- `For non-image files use type="file" (live viewer) or type="webpage" (URL) instead.`,
446
- );
447
- }
439
+ const { mimeType } = validateLocalImageFile(resolved);
448
440
  return {
449
441
  ...(input.data ?? {}),
450
442
  src: resolved,
451
443
  title: input.title ?? fileName,
452
444
  path: resolved,
453
- mimeType: mime,
445
+ mimeType,
454
446
  };
455
447
  }
456
448
 
@@ -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
+ }
@@ -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
- const ext = safePath.split('.').pop()?.toLowerCase() ?? '';
1109
- const contentType = IMAGE_MIME_MAP[ext] || 'application/octet-stream';
1110
- const data = readFileSync(safePath);
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,
@@ -3773,7 +3779,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3773
3779
  }
3774
3780
 
3775
3781
  if (url.pathname.startsWith('/api/canvas/image/') && req.method === 'GET') {
3776
- return handleCanvasImage(url.pathname);
3782
+ return await handleCanvasImage(url.pathname);
3777
3783
  }
3778
3784
 
3779
3785
  if (url.pathname === '/api/canvas/edge' && req.method === 'POST') {