pmx-canvas 0.1.27 → 0.1.29

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.
@@ -540,6 +540,7 @@ export function describeCanvasSchema(): {
540
540
  },
541
541
  components: clone(describeJsonRenderCatalog()),
542
542
  directives: [
543
+ { name: '$state', usage: '{ "$state": "/path/to/value" } — read a value from the state model by path (one-way). Use this to bind a value by path; there is no $path directive.' },
543
544
  { name: '$format', usage: '{ "$format": "currency"|"number"|"percent"|"date", "value": <num|state-ref>, "currency"?: "USD", "locale"?, "style"?, "options"? } — Intl-formatted string' },
544
545
  { name: '$math', usage: '{ "$math": "add"|"subtract"|"multiply"|"divide"|"mod"|"min"|"max"|"round"|"floor"|"ceil"|"abs", "a": <num>, "b"?: <num> }' },
545
546
  { name: '$concat', usage: '{ "$concat": [<value>, <value>, ...] } — join values into one string' },
@@ -36,6 +36,8 @@ import {
36
36
  createCanvasStreamingJsonRenderNode,
37
37
  MARKDOWN_NODE_DEFAULT_SIZE,
38
38
  MCP_APP_NODE_DEFAULT_SIZE,
39
+ IMAGE_NODE_DEFAULT_SIZE,
40
+ LEDGER_NODE_DEFAULT_SIZE,
39
41
  applyCanvasNodeUpdates,
40
42
  arrangeCanvasNodes,
41
43
  clearCanvas,
@@ -128,8 +130,14 @@ export class PmxCanvas extends EventEmitter {
128
130
  async start(options?: {
129
131
  open?: boolean;
130
132
  automationWebView?: boolean | CanvasAutomationWebViewOptions;
133
+ /**
134
+ * Bind a nearby free port when the preferred one is taken instead of
135
+ * failing. Default false (an explicit SDK port is honored exactly); the
136
+ * MCP auto-start opts in so a daemon already on the port can't crash it.
137
+ */
138
+ allowPortFallback?: boolean;
131
139
  }): Promise<void> {
132
- const base = startCanvasServer({ port: this._port, allowPortFallback: false });
140
+ const base = startCanvasServer({ port: this._port, allowPortFallback: options?.allowPortFallback ?? false });
133
141
  if (!base) {
134
142
  throw new Error(`Failed to start canvas server on port ${this._port}`);
135
143
  }
@@ -186,6 +194,11 @@ export class PmxCanvas extends EventEmitter {
186
194
  this._server = null;
187
195
  }
188
196
 
197
+ /**
198
+ * Add a node to the canvas and return the created node (including its `id`,
199
+ * resolved geometry, and data). Destructure `const { id } = canvas.addNode(...)`
200
+ * or keep the whole node — both work. (Previously returned a bare id string.)
201
+ */
189
202
  addNode(input: {
190
203
  type: CanvasNodeState['type'];
191
204
  title?: string;
@@ -205,12 +218,12 @@ export class PmxCanvas extends EventEmitter {
205
218
  width?: number;
206
219
  height?: number;
207
220
  strictSize?: boolean;
208
- }): string {
221
+ }): CanvasNodeState {
209
222
  if (input.type === 'webpage') {
210
223
  throw new Error('Use addWebpageNode for webpage nodes so page content is fetched and cached on the server.');
211
224
  }
212
225
  if (input.type === 'group') {
213
- return this.createGroup({
226
+ const groupId = this.createGroup({
214
227
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
215
228
  childIds: input.childIds ?? input.children ?? [],
216
229
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
@@ -220,6 +233,9 @@ export class PmxCanvas extends EventEmitter {
220
233
  ...(typeof input.color === 'string' ? { color: input.color } : {}),
221
234
  ...(input.childLayout ? { childLayout: input.childLayout } : {}),
222
235
  });
236
+ const groupNode = canvasState.getNode(groupId);
237
+ if (!groupNode) throw new Error(`Group node "${groupId}" was not created.`);
238
+ return groupNode;
223
239
  }
224
240
  const { id, needsCodeGraphRecompute } = addCanvasNode({
225
241
  ...input,
@@ -227,12 +243,20 @@ export class PmxCanvas extends EventEmitter {
227
243
  ? MARKDOWN_NODE_DEFAULT_SIZE.width
228
244
  : input.type === 'mcp-app'
229
245
  ? MCP_APP_NODE_DEFAULT_SIZE.width
230
- : 360,
246
+ : input.type === 'image'
247
+ ? IMAGE_NODE_DEFAULT_SIZE.width
248
+ : input.type === 'ledger'
249
+ ? LEDGER_NODE_DEFAULT_SIZE.width
250
+ : 360,
231
251
  defaultHeight: input.type === 'markdown'
232
252
  ? MARKDOWN_NODE_DEFAULT_SIZE.height
233
253
  : input.type === 'mcp-app'
234
254
  ? MCP_APP_NODE_DEFAULT_SIZE.height
235
- : 200,
255
+ : input.type === 'image'
256
+ ? IMAGE_NODE_DEFAULT_SIZE.height
257
+ : input.type === 'ledger'
258
+ ? LEDGER_NODE_DEFAULT_SIZE.height
259
+ : 200,
236
260
  fileMode: 'path',
237
261
  ...(input.strictSize ? { strictSize: true } : {}),
238
262
  });
@@ -245,7 +269,9 @@ export class PmxCanvas extends EventEmitter {
245
269
  });
246
270
  }
247
271
 
248
- return id;
272
+ const node = canvasState.getNode(id);
273
+ if (!node) throw new Error(`Node "${id}" was not created.`);
274
+ return node;
249
275
  }
250
276
 
251
277
  async addWebpageNode(input: {
@@ -93,6 +93,8 @@ import {
93
93
  addCanvasEdge,
94
94
  MARKDOWN_NODE_DEFAULT_SIZE,
95
95
  MCP_APP_NODE_DEFAULT_SIZE,
96
+ IMAGE_NODE_DEFAULT_SIZE,
97
+ LEDGER_NODE_DEFAULT_SIZE,
96
98
  applyCanvasNodeUpdates,
97
99
  appendCanvasJsonRenderStream,
98
100
  buildStructuredNodeUpdate,
@@ -1506,7 +1508,13 @@ async function handleCanvasImage(pathname: string): Promise<Response> {
1506
1508
  if (!src || src.startsWith('data:') || src.startsWith('http')) {
1507
1509
  return responseText('Not a file-based image', 400);
1508
1510
  }
1509
- const safePath = resolve(src);
1511
+ // Contain the file read to the active workspace. `src` comes from node data,
1512
+ // which any unauthenticated local caller can set — without this guard the
1513
+ // image route serves arbitrary host files (e.g. ../../etc/passwd).
1514
+ const safePath = resolveWorkspaceArtifactPath(src);
1515
+ if (!safePath) {
1516
+ return responseText('Image path is outside the workspace', 403);
1517
+ }
1510
1518
  if (!existsSync(safePath)) {
1511
1519
  return responseText('Image file not found', 404);
1512
1520
  }
@@ -1699,14 +1707,22 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1699
1707
  ? MARKDOWN_NODE_DEFAULT_SIZE.width
1700
1708
  : type === 'mcp-app'
1701
1709
  ? MCP_APP_NODE_DEFAULT_SIZE.width
1702
- : 360,
1710
+ : type === 'image'
1711
+ ? IMAGE_NODE_DEFAULT_SIZE.width
1712
+ : type === 'ledger'
1713
+ ? LEDGER_NODE_DEFAULT_SIZE.width
1714
+ : 360,
1703
1715
  defaultHeight: type === 'html'
1704
1716
  ? 640
1705
1717
  : type === 'markdown'
1706
1718
  ? MARKDOWN_NODE_DEFAULT_SIZE.height
1707
1719
  : type === 'mcp-app'
1708
1720
  ? MCP_APP_NODE_DEFAULT_SIZE.height
1709
- : 200,
1721
+ : type === 'image'
1722
+ ? IMAGE_NODE_DEFAULT_SIZE.height
1723
+ : type === 'ledger'
1724
+ ? LEDGER_NODE_DEFAULT_SIZE.height
1725
+ : 200,
1710
1726
  fileMode: 'auto',
1711
1727
  });
1712
1728
  } catch (error) {
@@ -2072,6 +2088,10 @@ async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
2072
2088
  ...(typeof body.outputPath === 'string'
2073
2089
  ? { outputPath: resolveWorkspacePath(body.outputPath, activeWorkspaceRoot) }
2074
2090
  : {}),
2091
+ // Script-path overrides are honored only when contained inside the
2092
+ // workspace (enforced by resolveTrustedScriptPath in
2093
+ // executeWebArtifactBuild), so they cannot point at an arbitrary host
2094
+ // script for bash execution.
2075
2095
  ...(typeof body.initScriptPath === 'string'
2076
2096
  ? { initScriptPath: body.initScriptPath }
2077
2097
  : {}),
@@ -5,11 +5,12 @@ import {
5
5
  readdirSync,
6
6
  mkdirSync,
7
7
  readFileSync,
8
+ realpathSync,
8
9
  statSync,
9
10
  unlinkSync,
10
11
  writeFileSync,
11
12
  } from 'node:fs';
12
- import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from 'node:path';
13
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
13
14
  import { ensureArtifactsDir, getWorkspaceRoot } from './artifact-paths.js';
14
15
  import { canvasState, type CanvasNodeState } from './canvas-state.js';
15
16
  import { findOpenCanvasPosition } from './placement.js';
@@ -338,6 +339,33 @@ async function runProcess(
338
339
  return { stdout: stdout.trim(), stderr: stderr.trim() };
339
340
  }
340
341
 
342
+ /**
343
+ * Resolve the init/bundle script path. Without an override the trusted bundled
344
+ * resolver is used. An override (a test/debugging escape hatch exposed on every
345
+ * surface — CLI --init-script-path/--bundle-script-path, the MCP tool, the HTTP
346
+ * endpoint, and the SDK) is only honored when it resolves inside the active
347
+ * workspace root — otherwise it would let a caller exec an arbitrary host script
348
+ * via bash as the server user. This containment is the single chokepoint that
349
+ * makes forwarding the field safe across all of those surfaces.
350
+ */
351
+ function resolveTrustedScriptPath(override: string | undefined, kind: 'init' | 'bundle'): string {
352
+ if (!override) return resolveWebArtifactScriptPath(kind);
353
+ const resolved = resolve(override);
354
+ // Compare real (symlink-resolved) paths on both sides so a legitimately
355
+ // contained override under a symlinked root (e.g. macOS /var -> /private/var,
356
+ // or a workspace that itself lives beneath a symlink) is not wrongly rejected.
357
+ // The candidate is realpath'd only when it exists; a non-existent escape path
358
+ // still fails the containment check below (or the existsSync guard at the call
359
+ // site). This keeps the guard strict — genuine escapes are still rejected.
360
+ const realRoot = realpathSync(currentWorkspaceRoot());
361
+ const realCandidate = existsSync(resolved) ? realpathSync(resolved) : resolved;
362
+ const workspaceRel = relative(realRoot, realCandidate);
363
+ if (workspaceRel === '..' || workspaceRel.startsWith(`..${sep}`) || isAbsolute(workspaceRel)) {
364
+ throw new Error(`Web-artifact ${kind} script override must resolve inside the workspace: ${override}`);
365
+ }
366
+ return resolved;
367
+ }
368
+
341
369
  export function resolveWebArtifactScriptPath(kind: 'init' | 'bundle'): string {
342
370
  const scriptFile = kind === 'init' ? 'init-artifact.sh' : 'bundle-artifact.sh';
343
371
  const candidates = [
@@ -459,10 +487,8 @@ export async function executeWebArtifactBuild(
459
487
  const slug = slugify(input.title);
460
488
  const projectPath = resolve(input.projectPath ?? join(artifactsDir, '.web-artifacts', slug));
461
489
  const outputPath = resolve(input.outputPath ?? join(artifactsDir, `${slug}.html`));
462
- const initScriptPath = resolve(input.initScriptPath ?? resolveWebArtifactScriptPath('init'));
463
- const bundleScriptPath = resolve(
464
- input.bundleScriptPath ?? resolveWebArtifactScriptPath('bundle'),
465
- );
490
+ const initScriptPath = resolveTrustedScriptPath(input.initScriptPath, 'init');
491
+ const bundleScriptPath = resolveTrustedScriptPath(input.bundleScriptPath, 'bundle');
466
492
  const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
467
493
 
468
494
  if (!existsSync(initScriptPath)) {