@zseven-w/dsh-openpencil 0.1.0-rc.1

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.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/THIRD_PARTY_NOTICES.md +69 -0
  4. package/cordis.patch.yml +6 -0
  5. package/docs/images/dsh-openpencil-overview.png +0 -0
  6. package/lib/client/details-compat.d.ts +28 -0
  7. package/lib/client/details-compat.js +10 -0
  8. package/lib/client/editor-bridge.d.ts +84 -0
  9. package/lib/client/editor-bridge.js +127 -0
  10. package/lib/client/editor-dock-layout.d.ts +15 -0
  11. package/lib/client/editor-dock-layout.js +43 -0
  12. package/lib/client/editor-recovery.d.ts +27 -0
  13. package/lib/client/editor-recovery.js +83 -0
  14. package/lib/client/editor-successor.d.ts +28 -0
  15. package/lib/client/editor-successor.js +109 -0
  16. package/lib/client/presentation-hydration.d.ts +36 -0
  17. package/lib/client/presentation-hydration.js +168 -0
  18. package/lib/client/selection-polling.d.ts +24 -0
  19. package/lib/client/selection-polling.js +79 -0
  20. package/lib/client/selection-store.d.ts +26 -0
  21. package/lib/client/selection-store.js +85 -0
  22. package/lib/client.js +4033 -0
  23. package/lib/design-tools.d.ts +21 -0
  24. package/lib/design-tools.js +214 -0
  25. package/lib/editor-host.d.ts +62 -0
  26. package/lib/editor-host.js +1147 -0
  27. package/lib/editor-recovery.d.ts +38 -0
  28. package/lib/editor-recovery.js +319 -0
  29. package/lib/index.d.ts +24 -0
  30. package/lib/index.js +132 -0
  31. package/lib/mcp-client.d.ts +47 -0
  32. package/lib/mcp-client.js +148 -0
  33. package/lib/new-tool.d.ts +23 -0
  34. package/lib/new-tool.js +136 -0
  35. package/lib/presentation-hydration.d.ts +55 -0
  36. package/lib/presentation-hydration.js +561 -0
  37. package/lib/renderer.d.ts +203 -0
  38. package/lib/renderer.js +772 -0
  39. package/lib/tool-names.d.ts +13 -0
  40. package/lib/tool-names.js +19 -0
  41. package/lib/tool.d.ts +27 -0
  42. package/lib/tool.js +221 -0
  43. package/lib/types/client/details-compat.d.ts +28 -0
  44. package/lib/types/client/editor-bridge.d.ts +84 -0
  45. package/lib/types/client/editor-dock-layout.d.ts +15 -0
  46. package/lib/types/client/editor-modal.d.ts +54 -0
  47. package/lib/types/client/editor-panel.d.ts +127 -0
  48. package/lib/types/client/editor-recovery.d.ts +27 -0
  49. package/lib/types/client/editor-successor.d.ts +28 -0
  50. package/lib/types/client/editor-workbench-host.d.ts +43 -0
  51. package/lib/types/client/frame-gallery.d.ts +98 -0
  52. package/lib/types/client/index.d.ts +176 -0
  53. package/lib/types/client/presentation-hydration.d.ts +36 -0
  54. package/lib/types/client/selection-dock.d.ts +18 -0
  55. package/lib/types/client/selection-polling.d.ts +24 -0
  56. package/lib/types/client/selection-store.d.ts +26 -0
  57. package/lib/types/tool-names.d.ts +13 -0
  58. package/lib/viewer-assets/canvaskit/canvaskit.js +288 -0
  59. package/lib/viewer-assets/canvaskit/canvaskit.wasm +0 -0
  60. package/lib/viewer-assets/manifest.json +22 -0
  61. package/lib/viewer-assets/op_web_sdk_bg.wasm +0 -0
  62. package/lib/viewer-assets/sdk.js +225 -0
  63. package/lib/viewer-assets.d.ts +74 -0
  64. package/lib/viewer-assets.js +303 -0
  65. package/package.json +95 -0
  66. package/scripts/build-client.mjs +27 -0
  67. package/scripts/run-tool.mjs +63 -0
  68. package/scripts/sync-viewer-assets.mjs +304 -0
  69. package/scripts/test-host.mjs +311 -0
  70. package/scripts/test-viewer-assets.mjs +54 -0
  71. package/src/client/details-compat.ts +44 -0
  72. package/src/client/editor-bridge.ts +170 -0
  73. package/src/client/editor-dock-layout.ts +56 -0
  74. package/src/client/editor-modal.tsx +607 -0
  75. package/src/client/editor-panel.tsx +823 -0
  76. package/src/client/editor-recovery.ts +116 -0
  77. package/src/client/editor-successor.ts +136 -0
  78. package/src/client/editor-workbench-host.tsx +280 -0
  79. package/src/client/frame-gallery.tsx +546 -0
  80. package/src/client/index.tsx +987 -0
  81. package/src/client/presentation-hydration.ts +210 -0
  82. package/src/client/react-dom-compat.d.ts +22 -0
  83. package/src/client/selection-dock.tsx +89 -0
  84. package/src/client/selection-polling.ts +93 -0
  85. package/src/client/selection-store.ts +108 -0
  86. package/src/design-tools.ts +242 -0
  87. package/src/editor-host.ts +1238 -0
  88. package/src/editor-recovery.ts +374 -0
  89. package/src/index.ts +219 -0
  90. package/src/mcp-client.ts +193 -0
  91. package/src/new-tool.ts +160 -0
  92. package/src/presentation-hydration.ts +634 -0
  93. package/src/renderer.ts +884 -0
  94. package/src/tool-names.ts +21 -0
  95. package/src/tool.ts +258 -0
  96. package/src/viewer-assets.ts +353 -0
@@ -0,0 +1,884 @@
1
+ /**
2
+ * Offscreen rendering + signed HTTP delivery for `.op` documents.
3
+ *
4
+ * `openpencil_render` snapshots the source document, uses OpenPencil's own
5
+ * headless exporter for design-fidelity PNG output, retaining every
6
+ * top-level frame for the conversation gallery, and only invokes Jian
7
+ * as an explicitly disclosed runtime-preview fallback when the exact binary
8
+ * is unavailable. Content-addressed image/document capabilities bind name,
9
+ * byte length, and SHA-256 without exposing arbitrary host paths. Serving
10
+ * refuses symbolic links so a delivered artifact cannot be redirected.
11
+ *
12
+ * Model-visible result values stay plain JSON; the browser-only envelope
13
+ * rides `presentationMeta` (see `tool.ts`). Never return an ImageBlock.
14
+ * @module dsh-openpencil/renderer
15
+ */
16
+
17
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
18
+ import { constants as fsConstants, statSync } from 'node:fs'
19
+ import type { Stats } from 'node:fs'
20
+ import type { FileHandle } from 'node:fs/promises'
21
+ import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
22
+ import type { IncomingMessage, ServerResponse } from 'node:http'
23
+ import { spawn } from 'node:child_process'
24
+ import { homedir } from 'node:os'
25
+ import { basename, delimiter, isAbsolute, join, relative, resolve, sep } from 'node:path'
26
+ import { randomUUID } from 'node:crypto'
27
+ import type { JsonValue } from '@deepseek-ai/dsh-tools'
28
+ import type { ViewerGrant } from './viewer-assets.js'
29
+ import type { EditorGrant } from './editor-host.js'
30
+ import { OPENPENCIL_RENDER_TOOL_NAME } from './tool-names.js'
31
+
32
+ /** HTTP prefix owned by the render capability route. */
33
+ export const RENDER_ROUTE_PREFIX = '/_dsh/dsh-openpencil/render'
34
+
35
+ /** Presentation metadata key reserved by the browser half of this package. */
36
+ export const PRESENTATION_META_KEY = '$dshOpenPencil'
37
+
38
+ /** Refuse to deliver anything above this size (also enforced at render time). */
39
+ export const MAX_RENDER_BYTES = 32 * 1024 * 1024
40
+
41
+ /** Refuse unusually large source documents before copying them into managed state. */
42
+ export const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024
43
+
44
+ /** Bound decoded image geometry as well as compressed bytes. */
45
+ const MAX_RENDER_DIMENSION = 32_768
46
+ const MAX_RENDER_PIXELS = 128 * 1024 * 1024
47
+
48
+ /** Hard per-render wall clock for either renderer. */
49
+ const RENDER_TIMEOUT_MS = 60_000
50
+
51
+ const KEY_BYTES = 32
52
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/
53
+ const MAX_TOKEN_LENGTH = 16 * 1024
54
+
55
+ /** One render delivery capability. */
56
+ interface LegacyRenderTokenPayload {
57
+ v: 1
58
+ path: string
59
+ filename: string
60
+ bytes: number
61
+ }
62
+
63
+ interface ArtifactTokenPayload {
64
+ v: 2
65
+ kind: 'image' | 'document'
66
+ filename: string
67
+ bytes: number
68
+ sha256: string
69
+ }
70
+
71
+ type RenderTokenPayload = LegacyRenderTokenPayload | ArtifactTokenPayload
72
+
73
+ /** Browser grant paired to one rendered PNG. */
74
+ export interface RenderGrant {
75
+ path: string
76
+ previewUrl: string
77
+ downloadUrl: string
78
+ width?: number
79
+ height?: number
80
+ id?: string
81
+ name?: string
82
+ index?: number
83
+ }
84
+
85
+ /** One immutable top-level frame retained from an exact render. */
86
+ export interface RenderFrame {
87
+ path: string
88
+ filename: string
89
+ mimeType: 'image/png'
90
+ bytes: number
91
+ width: number
92
+ height: number
93
+ sha256: string
94
+ /** Canonical top-level node identity and user-facing name. */
95
+ id?: string
96
+ name?: string
97
+ index?: number
98
+ }
99
+
100
+ export interface DocumentSnapshot {
101
+ path: string
102
+ filename: string
103
+ mimeType: 'application/json'
104
+ bytes: number
105
+ sha256: string
106
+ }
107
+
108
+ export interface DocumentGrant extends DocumentSnapshot {
109
+ url: string
110
+ previewUrl: string
111
+ downloadUrl: string
112
+ }
113
+
114
+ /** Canonical result shape the tool returns and the envelope enriches. */
115
+ export interface RenderResult {
116
+ path: string
117
+ filename: string
118
+ mimeType: 'image/png'
119
+ kind: 'image'
120
+ description: string
121
+ sourceTool: typeof OPENPENCIL_RENDER_TOOL_NAME
122
+ previewIntent: 'image'
123
+ bytes: number
124
+ width?: number
125
+ height?: number
126
+ sha256?: string
127
+ sourcePath?: string
128
+ renderer?: 'openpencil' | 'jian'
129
+ rendererBinary?: string
130
+ fidelity?: 'exact' | 'runtime-preview'
131
+ warnings?: string[]
132
+ /** Ordered top-level frames. The first entry is also exposed by legacy image fields. */
133
+ frames?: RenderFrame[]
134
+ frameCount?: number
135
+ editable?: boolean
136
+ /** Live-card intent: expand the editor once after a newly-created design renders. */
137
+ autoOpenEditor?: boolean
138
+ document?: DocumentSnapshot
139
+ note?: string
140
+ }
141
+
142
+ function dshHome(): string {
143
+ const env = process.env.DSH_HOME?.trim()
144
+ return env === undefined || env.length === 0 ? join(homedir(), '.dsh') : resolve(env)
145
+ }
146
+
147
+ /** Plugin-managed state root (mirrors the dsh-vision-toolkit convention). */
148
+ export function stateRoot(): string {
149
+ return join(dshHome(), 'cache', 'dsh-openpencil')
150
+ }
151
+
152
+ /** Plugin-managed render output directory. */
153
+ export function renderDir(): string {
154
+ return join(stateRoot(), 'renders')
155
+ }
156
+
157
+ /** Content-addressed immutable `.op` snapshots served to the web viewer. */
158
+ export function snapshotDir(): string {
159
+ return join(stateRoot(), 'snapshots')
160
+ }
161
+
162
+ function isRecord(value: unknown): value is Record<string, unknown> {
163
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
164
+ }
165
+
166
+ function mac(key: Buffer, payload: string): Buffer {
167
+ return createHmac('sha256', key).update(payload).digest()
168
+ }
169
+
170
+ function safeEqual(left: Buffer, right: Buffer): boolean {
171
+ return left.length === right.length && timingSafeEqual(left, right)
172
+ }
173
+
174
+ async function readKey(path: string): Promise<Buffer> {
175
+ const info = await lstat(path)
176
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error('render access key is not a regular file')
177
+ const key = await readFile(path)
178
+ if (key.length !== KEY_BYTES) throw new Error('render access key has an invalid length')
179
+ return key
180
+ }
181
+
182
+ /** Load or atomically create the per-DSH-home signing key. */
183
+ export async function prepareRenderAccessKey(): Promise<Buffer> {
184
+ await mkdir(stateRoot(), { recursive: true, mode: 0o700 })
185
+ const path = join(stateRoot(), 'render-access.key')
186
+ try {
187
+ return await readKey(path)
188
+ } catch (error) {
189
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
190
+ }
191
+ const candidate = randomBytes(KEY_BYTES)
192
+ try {
193
+ await writeFile(path, candidate, { flag: 'wx', mode: 0o600 })
194
+ return candidate
195
+ } catch (error) {
196
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
197
+ return readKey(path)
198
+ }
199
+ }
200
+
201
+ function parsePayload(value: unknown): RenderTokenPayload | undefined {
202
+ if (!isRecord(value)) return undefined
203
+ if (value.v === 2) {
204
+ if (
205
+ (value.kind !== 'image' && value.kind !== 'document')
206
+ || typeof value.filename !== 'string'
207
+ || basename(value.filename) !== value.filename
208
+ || typeof value.bytes !== 'number'
209
+ || !Number.isSafeInteger(value.bytes)
210
+ || value.bytes <= 0
211
+ || value.bytes > (value.kind === 'image' ? MAX_RENDER_BYTES : MAX_DOCUMENT_BYTES)
212
+ || typeof value.sha256 !== 'string'
213
+ || !/^[a-f0-9]{64}$/.test(value.sha256)
214
+ ) return undefined
215
+ if (value.kind === 'image' && !/^render-[A-Za-z0-9-]+\.png$/.test(value.filename)) return undefined
216
+ if (value.kind === 'document' && value.filename !== `${value.sha256}.op`) return undefined
217
+ return value as unknown as ArtifactTokenPayload
218
+ }
219
+ if (
220
+ value.v !== 1
221
+ || typeof value.path !== 'string'
222
+ || !isAbsolute(value.path)
223
+ || typeof value.filename !== 'string'
224
+ || basename(value.path) !== value.filename
225
+ || typeof value.bytes !== 'number'
226
+ || !Number.isSafeInteger(value.bytes)
227
+ || value.bytes < 0
228
+ || value.bytes > MAX_RENDER_BYTES
229
+ ) return undefined
230
+ return value as unknown as RenderTokenPayload
231
+ }
232
+
233
+ function payloadPath(payload: RenderTokenPayload): string {
234
+ if (payload.v === 1) return payload.path
235
+ return join(payload.kind === 'image' ? renderDir() : snapshotDir(), payload.filename)
236
+ }
237
+
238
+ function payloadMimeType(payload: RenderTokenPayload): 'image/png' | 'application/json' {
239
+ return payload.v === 1 || payload.kind === 'image' ? 'image/png' : 'application/json'
240
+ }
241
+
242
+ /**
243
+ * Walk every path component from the managed root with `lstat`, refusing
244
+ * any symbolic link — even one whose target stays inside the root — and
245
+ * finish with a `realpath` containment check.
246
+ */
247
+ async function assertNoSymlinkPath(root: string, path: string): Promise<void> {
248
+ const rootInfo = await lstat(root)
249
+ if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) throw new Error('render root is not a real directory')
250
+ const rel = relative(root, path)
251
+ if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
252
+ throw new Error('render path escaped its managed root')
253
+ }
254
+ let current = root
255
+ const parts = rel.split(sep)
256
+ for (let index = 0; index < parts.length; index += 1) {
257
+ const part = parts[index]
258
+ if (part === undefined || part.length === 0 || part === '.' || part === '..') throw new Error('render path is malformed')
259
+ current = join(current, part)
260
+ const info = await lstat(current)
261
+ if (info.isSymbolicLink()) throw new Error('render path contains a symbolic link')
262
+ const final = index === parts.length - 1
263
+ if (final ? !info.isFile() : !info.isDirectory()) throw new Error('render path contains an unexpected entry type')
264
+ }
265
+ const [realRoot, realFile] = await Promise.all([realpath(root), realpath(path)])
266
+ const relReal = relative(realRoot, realFile)
267
+ if (relReal === '..' || relReal.startsWith(`..${sep}`) || isAbsolute(relReal)) {
268
+ throw new Error('render path escaped its managed root')
269
+ }
270
+ }
271
+
272
+ function sameFile(opened: Stats, current: Stats): boolean {
273
+ if (opened.dev === 0 || current.dev === 0 || opened.ino === 0 || current.ino === 0) return true
274
+ return opened.dev === current.dev && opened.ino === current.ino
275
+ }
276
+
277
+ async function openVerifiedRender(payload: RenderTokenPayload): Promise<{ handle: FileHandle; info: Stats }> {
278
+ const root = payload.v === 1 || payload.kind === 'image' ? renderDir() : snapshotDir()
279
+ const path = payloadPath(payload)
280
+ await assertNoSymlinkPath(root, path)
281
+ const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0
282
+ const handle = await open(path, fsConstants.O_RDONLY | noFollow)
283
+ try {
284
+ const info = await handle.stat()
285
+ if (!info.isFile() || info.size !== payload.bytes) throw new Error('render no longer matches its delivered descriptor')
286
+ const current = await lstat(path)
287
+ if (current.isSymbolicLink() || !current.isFile() || !sameFile(info, current)) {
288
+ throw new Error('render changed while it was being opened')
289
+ }
290
+ if (payload.v === 2) {
291
+ // Hash the same opened inode that will be served. Reading the pathname
292
+ // here would leave a rename race between verification and streaming.
293
+ const bytes = await handle.readFile()
294
+ const digest = createHash('sha256').update(bytes).digest('hex')
295
+ if (digest !== payload.sha256) throw new Error('artifact content hash changed')
296
+ }
297
+ await assertNoSymlinkPath(root, path)
298
+ return { handle, info }
299
+ } catch (error) {
300
+ await handle.close().catch(() => {})
301
+ throw error
302
+ }
303
+ }
304
+
305
+ function asciiFilename(filename: string): string {
306
+ const fallback = filename.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 160)
307
+ return fallback.length === 0 ? 'render.png' : fallback
308
+ }
309
+
310
+ function securityHeaders(res: ServerResponse, payload: RenderTokenPayload, download: boolean): void {
311
+ res.setHeader('Content-Type', payloadMimeType(payload))
312
+ res.setHeader('Content-Length', String(payload.bytes))
313
+ res.setHeader('Content-Disposition', `${download ? 'attachment' : 'inline'}; filename="${asciiFilename(payload.filename)}"`)
314
+ res.setHeader('X-Content-Type-Options', 'nosniff')
315
+ res.setHeader('Cache-Control', 'private, no-store, max-age=0')
316
+ res.setHeader('Referrer-Policy', 'no-referrer')
317
+ res.setHeader('Cross-Origin-Resource-Policy', 'same-origin')
318
+ res.setHeader('Content-Security-Policy', "sandbox; default-src 'none'")
319
+ }
320
+
321
+ /** Signed render-capability encoder and safe route handler. */
322
+ export class RenderAccessController {
323
+ private routeCount = 0
324
+
325
+ constructor(private readonly key: Buffer) {
326
+ if (key.length !== KEY_BYTES) throw new TypeError(`render access key must be ${KEY_BYTES} bytes`)
327
+ }
328
+
329
+ /** Whether at least one HTTP carrier currently owns the route. */
330
+ get routeAvailable(): boolean {
331
+ return this.routeCount > 0
332
+ }
333
+
334
+ /** Mark one route attachment; the returned disposer removes that attachment. */
335
+ attachRoute(): () => void {
336
+ this.routeCount += 1
337
+ let active = true
338
+ return () => {
339
+ if (!active) return
340
+ active = false
341
+ this.routeCount -= 1
342
+ }
343
+ }
344
+
345
+ /** Mint a deterministic, tamper-evident capability for one render result. */
346
+ sign(result: RenderResult): string {
347
+ if (result.sha256 !== undefined) {
348
+ return this.signArtifact({
349
+ kind: 'image',
350
+ filename: result.filename,
351
+ bytes: result.bytes,
352
+ sha256: result.sha256,
353
+ })
354
+ }
355
+ const payload: RenderTokenPayload = {
356
+ v: 1,
357
+ path: result.path,
358
+ filename: result.filename,
359
+ bytes: result.bytes,
360
+ }
361
+ const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
362
+ return `${encoded}.${mac(this.key, encoded).toString('base64url')}`
363
+ }
364
+
365
+ /** Mint an immutable capability without embedding an absolute local path. */
366
+ signArtifact(artifact: Omit<ArtifactTokenPayload, 'v'>): string {
367
+ const payload: ArtifactTokenPayload = { v: 2, ...artifact }
368
+ const parsed = parsePayload(payload)
369
+ if (parsed === undefined || parsed.v !== 2) throw new Error('invalid artifact descriptor')
370
+ const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
371
+ return `${encoded}.${mac(this.key, encoded).toString('base64url')}`
372
+ }
373
+
374
+ /** Verify and decode one capability without touching the filesystem. */
375
+ verify(token: string): RenderTokenPayload | undefined {
376
+ if (token.length === 0 || token.length > MAX_TOKEN_LENGTH || !TOKEN_PATTERN.test(token)) return undefined
377
+ const [encoded, signature] = token.split('.')
378
+ if (encoded === undefined || signature === undefined) return undefined
379
+ let supplied: Buffer
380
+ try {
381
+ supplied = Buffer.from(signature, 'base64url')
382
+ } catch {
383
+ return undefined
384
+ }
385
+ if (!safeEqual(mac(this.key, encoded), supplied)) return undefined
386
+ try {
387
+ return parsePayload(JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')))
388
+ } catch {
389
+ return undefined
390
+ }
391
+ }
392
+
393
+ /** Serve one GET/HEAD capability request. */
394
+ async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
395
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
396
+ res.setHeader('Allow', 'GET, HEAD')
397
+ res.writeHead(405)
398
+ res.end()
399
+ return
400
+ }
401
+ let url: URL
402
+ try {
403
+ url = new URL(req.url ?? '/', 'http://dsh.local')
404
+ } catch {
405
+ res.writeHead(400)
406
+ res.end()
407
+ return
408
+ }
409
+ const prefix = `${RENDER_ROUTE_PREFIX}/`
410
+ if (!url.pathname.startsWith(prefix) || url.pathname.slice(prefix.length).includes('/')) {
411
+ res.writeHead(404)
412
+ res.end()
413
+ return
414
+ }
415
+ let token: string
416
+ try {
417
+ token = decodeURIComponent(url.pathname.slice(prefix.length))
418
+ } catch {
419
+ res.writeHead(404)
420
+ res.end()
421
+ return
422
+ }
423
+ const payload = this.verify(token)
424
+ if (payload === undefined) {
425
+ res.writeHead(404)
426
+ res.end()
427
+ return
428
+ }
429
+ const downloadValue = url.searchParams.get('download')
430
+ if ([...url.searchParams.keys()].some(key => key !== 'download') || (downloadValue !== null && downloadValue !== '1')) {
431
+ res.writeHead(400)
432
+ res.end()
433
+ return
434
+ }
435
+ let opened: { handle: FileHandle; info: Stats }
436
+ try {
437
+ opened = await openVerifiedRender(payload)
438
+ } catch {
439
+ res.writeHead(404)
440
+ res.end()
441
+ return
442
+ }
443
+ securityHeaders(res, payload, downloadValue === '1')
444
+ res.writeHead(200)
445
+ if (req.method === 'HEAD') {
446
+ await opened.handle.close().catch(() => {})
447
+ res.end()
448
+ return
449
+ }
450
+ const stream = opened.handle.createReadStream({ autoClose: true, start: 0, end: opened.info.size - 1 })
451
+ stream.on('error', () => {
452
+ if (!res.headersSent) res.writeHead(500)
453
+ res.destroy()
454
+ })
455
+ stream.pipe(res)
456
+ }
457
+ }
458
+
459
+ /** Expand `~` / `~/` prefixes (the model frequently writes home-relative paths). */
460
+ export function expandUserHome(raw: string): string {
461
+ if (raw === '~') return homedir()
462
+ if (raw.startsWith('~/') || raw.startsWith(`~${sep}`)) return join(homedir(), raw.slice(2))
463
+ return raw
464
+ }
465
+
466
+ /**
467
+ * Validate the `.op` input path: expand `~`, resolve against the session
468
+ * workspace, realpath, and require a regular `.op` file.
469
+ */
470
+ export async function resolveInputFile(raw: string, cwd: string): Promise<string> {
471
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
472
+ throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: path is required`)
473
+ }
474
+ const expanded = expandUserHome(raw.trim())
475
+ const target = isAbsolute(expanded) ? expanded : resolve(cwd, expanded)
476
+ let real: string
477
+ try {
478
+ real = await realpath(target)
479
+ } catch {
480
+ throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: .op file not found: ${raw}`)
481
+ }
482
+ const info = await stat(real)
483
+ if (!info.isFile()) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: not a regular file: ${raw}`)
484
+ const extension = real.slice(real.lastIndexOf('.')).toLowerCase()
485
+ if (extension !== '.op') {
486
+ throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: expected a .op file (got "${extension || '(none)'}")`)
487
+ }
488
+ return real
489
+ }
490
+
491
+ /** Freeze source bytes before rendering so preview and web viewer cannot diverge. */
492
+ export async function createDocumentSnapshot(input: string): Promise<DocumentSnapshot> {
493
+ const bytes = await readFile(input)
494
+ if (bytes.length === 0) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: source document is empty`)
495
+ if (bytes.length > MAX_DOCUMENT_BYTES) {
496
+ throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: source document exceeds ${MAX_DOCUMENT_BYTES} bytes`)
497
+ }
498
+ const sha256 = createHash('sha256').update(bytes).digest('hex')
499
+ const filename = `${sha256}.op`
500
+ await mkdir(snapshotDir(), { recursive: true, mode: 0o700 })
501
+ const path = join(snapshotDir(), filename)
502
+ try {
503
+ await writeFile(path, bytes, { flag: 'wx', mode: 0o600 })
504
+ } catch (error) {
505
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
506
+ const existing = await readFile(path)
507
+ if (existing.length !== bytes.length || createHash('sha256').update(existing).digest('hex') !== sha256) {
508
+ throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: content-addressed snapshot was modified`)
509
+ }
510
+ }
511
+ return { path, filename, mimeType: 'application/json', bytes: bytes.length, sha256 }
512
+ }
513
+
514
+ /** Locate the exact OpenPencil renderer, preferring an explicit override. */
515
+ export function findOpenPencilBinary(): string | undefined {
516
+ const override = process.env.DSH_OPENPENCIL_BINARY?.trim()
517
+ || process.env.DSH_OPENPENCIL_DESKTOP?.trim()
518
+ const candidates = [
519
+ ...(override === undefined || override.length === 0 ? [] : [expandUserHome(override)]),
520
+ '/Applications/OpenPencil.app/Contents/MacOS/openpencil-desktop',
521
+ join(homedir(), 'Applications', 'OpenPencil.app', 'Contents', 'MacOS', 'openpencil-desktop'),
522
+ ]
523
+ const pathEnv = process.env.PATH ?? ''
524
+ for (const dir of pathEnv.split(delimiter)) {
525
+ if (dir.length > 0) candidates.push(join(dir, 'openpencil-desktop'))
526
+ }
527
+ for (const candidate of candidates) {
528
+ try {
529
+ if (statSync(candidate).isFile()) return candidate
530
+ } catch {
531
+ // keep looking
532
+ }
533
+ }
534
+ return undefined
535
+ }
536
+
537
+ /** Locate the `jian` binary: env override, known build location, then PATH. */
538
+ export function findJianBinary(): string {
539
+ const override = process.env.DSH_OPENPENCIL_JIAN?.trim()
540
+ if (override !== undefined && override.length > 0) return override
541
+ const candidates = [join(homedir(), 'workspace', 'jian', 'target', 'release', 'jian')]
542
+ for (const candidate of candidates) {
543
+ try {
544
+ if (statSync(candidate).isFile()) return candidate
545
+ } catch {
546
+ // keep looking
547
+ }
548
+ }
549
+ const pathEnv = process.env.PATH ?? ''
550
+ for (const dir of pathEnv.split(':')) {
551
+ if (dir.length === 0) continue
552
+ const candidate = join(dir, 'jian')
553
+ try {
554
+ if (statSync(candidate).isFile()) return candidate
555
+ } catch {
556
+ // keep looking
557
+ }
558
+ }
559
+ return 'jian'
560
+ }
561
+
562
+ /** Parse the physical size from `jian render`'s success line. */
563
+ export function parseRenderSize(stdout: string): { width?: number; height?: number } {
564
+ const match = /\((\d+)x(\d+) physical/.exec(stdout)
565
+ if (match === null) return {}
566
+ return { width: Number(match[1]), height: Number(match[2]) }
567
+ }
568
+
569
+ /**
570
+ * Run `jian render` for one input and wait for the PNG on disk.
571
+ * Observes `signal` so caller cancellation stops the child promptly.
572
+ */
573
+ export async function runJianRender(options: {
574
+ binary: string
575
+ input: string
576
+ out: string
577
+ width?: number
578
+ height?: number
579
+ scale?: number
580
+ signal: AbortSignal
581
+ }): Promise<{ stdout: string; stderr: string }> {
582
+ const args = ['render', options.input, '--out', options.out]
583
+ if (options.width !== undefined) args.push('--width', String(options.width))
584
+ if (options.height !== undefined) args.push('--height', String(options.height))
585
+ if (options.scale !== undefined && options.scale !== 1) args.push('--scale', String(options.scale))
586
+
587
+ const child = spawn(options.binary, args, {
588
+ stdio: ['ignore', 'pipe', 'pipe'],
589
+ env: process.env,
590
+ })
591
+ const stdoutChunks: Buffer[] = []
592
+ const stderrChunks: Buffer[] = []
593
+ child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk))
594
+ child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk))
595
+
596
+ const timeout = setTimeout(() => {
597
+ child.kill('SIGKILL')
598
+ }, RENDER_TIMEOUT_MS)
599
+
600
+ const onAbort = (): void => {
601
+ child.kill('SIGKILL')
602
+ }
603
+ options.signal.addEventListener('abort', onAbort, { once: true })
604
+
605
+ try {
606
+ const code = await new Promise<number | null>((resolveCode, reject) => {
607
+ child.on('error', reject)
608
+ child.on('close', (closeCode) => resolveCode(closeCode))
609
+ })
610
+ clearTimeout(timeout)
611
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8')
612
+ const stderr = Buffer.concat(stderrChunks).toString('utf8')
613
+ if (code !== 0) {
614
+ const detail = stderr.trim() !== '' ? stderr.trim() : stdout.trim()
615
+ throw new Error(`jian render exited with ${String(code)}${detail !== '' ? `: ${detail}` : ''}`)
616
+ }
617
+ return { stdout, stderr }
618
+ } finally {
619
+ clearTimeout(timeout)
620
+ options.signal.removeEventListener('abort', onAbort)
621
+ }
622
+ }
623
+
624
+ export class RendererBinaryMissingError extends Error {
625
+ constructor(message: string, options?: ErrorOptions) {
626
+ super(message, options)
627
+ this.name = 'RendererBinaryMissingError'
628
+ }
629
+ }
630
+
631
+ interface SourceFrameDescriptor {
632
+ id: string
633
+ name?: string
634
+ index: number
635
+ }
636
+
637
+ function rendererFilename(id: string): string {
638
+ return `${id.replace(/[^A-Za-z0-9_-]/g, '_')}.png`
639
+ }
640
+
641
+ /** Read active-page top-level metadata without changing renderer semantics. */
642
+ async function sourceFrameDescriptors(path: string): Promise<SourceFrameDescriptor[]> {
643
+ try {
644
+ const value: unknown = JSON.parse(await readFile(path, 'utf8'))
645
+ if (!isRecord(value)) return []
646
+ const editorMeta = isRecord(value.editorMeta) ? value.editorMeta : undefined
647
+ const rawPageIndex = editorMeta?.activePageIndex ?? editorMeta?.active_page_index
648
+ const pageIndex = typeof rawPageIndex === 'number' && Number.isInteger(rawPageIndex) && rawPageIndex >= 0
649
+ ? rawPageIndex
650
+ : 0
651
+ const pages = Array.isArray(value.pages) ? value.pages.filter(isRecord) : undefined
652
+ const selectedPage = pages === undefined || pages.length === 0
653
+ ? undefined
654
+ : pages[Math.min(pageIndex, pages.length - 1)]
655
+ const children = Array.isArray(selectedPage?.children)
656
+ ? selectedPage.children
657
+ : Array.isArray(value.children) ? value.children : []
658
+ return children.flatMap((child, index) => {
659
+ if (!isRecord(child) || typeof child.id !== 'string' || child.id.length === 0) return []
660
+ return [{
661
+ id: child.id,
662
+ ...(typeof child.name === 'string' && child.name.length > 0 ? { name: child.name } : {}),
663
+ index,
664
+ }]
665
+ })
666
+ } catch {
667
+ // The exact renderer remains authoritative; metadata is presentation-only.
668
+ return []
669
+ }
670
+ }
671
+
672
+ /** Render all top-level active-page nodes through OpenPencil's exact scene exporter. */
673
+ export async function runOpenPencilRender(options: {
674
+ binary: string
675
+ input: string
676
+ scale?: number
677
+ signal: AbortSignal
678
+ }): Promise<{
679
+ png: string
680
+ frames: Array<{ png: string; id?: string; name?: string; index: number }>
681
+ warnings: string[]
682
+ stdout: string
683
+ stderr: string
684
+ }> {
685
+ const scale = options.scale ?? 1
686
+ if (!Number.isFinite(scale) || scale <= 0 || scale > 8) {
687
+ throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: scale must be a finite number greater than 0 and at most 8`)
688
+ }
689
+ await mkdir(stateRoot(), { recursive: true, mode: 0o700 })
690
+ const tempRoot = await mkdtemp(join(stateRoot(), 'exact-'))
691
+ const outDir = join(tempRoot, 'shots')
692
+ await mkdir(outDir, { mode: 0o700 })
693
+ const env: NodeJS.ProcessEnv = { ...process.env, OPENPENCIL_RENDER_MARGIN: '0' }
694
+ delete env.OPENPENCIL_DUMP_LAYOUT
695
+ const child = spawn(options.binary, ['--render-shots', options.input, outDir, String(scale)], {
696
+ stdio: ['ignore', 'pipe', 'pipe'],
697
+ env,
698
+ })
699
+ const stdoutChunks: Buffer[] = []
700
+ const stderrChunks: Buffer[] = []
701
+ child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk))
702
+ child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk))
703
+ let timedOut = false
704
+ const timeout = setTimeout(() => {
705
+ timedOut = true
706
+ child.kill('SIGKILL')
707
+ }, RENDER_TIMEOUT_MS)
708
+ const onAbort = (): void => { child.kill('SIGKILL') }
709
+ options.signal.addEventListener('abort', onAbort, { once: true })
710
+ try {
711
+ const code = await new Promise<number | null>((resolveCode, reject) => {
712
+ child.on('error', (error: NodeJS.ErrnoException) => {
713
+ if (error.code === 'ENOENT') reject(new RendererBinaryMissingError(`OpenPencil binary not found: ${options.binary}`, { cause: error }))
714
+ else reject(error)
715
+ })
716
+ child.on('close', resolveCode)
717
+ })
718
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8')
719
+ const stderr = Buffer.concat(stderrChunks).toString('utf8')
720
+ if (options.signal.aborted) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: OpenPencil render was cancelled`)
721
+ if (timedOut) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: OpenPencil render timed out after ${RENDER_TIMEOUT_MS} ms`)
722
+ if (code !== 0) throw new Error(`OpenPencil render exited with ${String(code)}${stderr.trim() === '' ? '' : `: ${stderr.trim()}`}`)
723
+ const discoveredPngs = (await readdir(outDir)).filter(name => name.toLowerCase().endsWith('.png'))
724
+ const sourceFrames = await sourceFrameDescriptors(options.input)
725
+ const writtenPrefix = 'render-shots: wrote '
726
+ const writtenPngs = stderr
727
+ .split(/\r?\n/)
728
+ .filter(line => line.startsWith(writtenPrefix))
729
+ .map(line => basename(line.slice(writtenPrefix.length).trim()))
730
+ .filter(name => discoveredPngs.includes(name))
731
+ const sourceOrderedPngs = sourceFrames
732
+ .map(frame => rendererFilename(frame.id))
733
+ .filter(name => discoveredPngs.includes(name))
734
+ // Prefer canonical active-page child order. The CLI also reports files in
735
+ // that order, which covers document variants our lightweight JSON metadata
736
+ // reader does not understand. Unknown files are appended stably.
737
+ const pngs = [...new Set([
738
+ ...sourceOrderedPngs,
739
+ ...writtenPngs,
740
+ ...discoveredPngs
741
+ .filter(name => !sourceOrderedPngs.includes(name) && !writtenPngs.includes(name))
742
+ .sort(),
743
+ ])]
744
+ if (pngs.length === 0) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: OpenPencil produced no PNG`)
745
+ const sourceFrameByPng = new Map(sourceFrames.map(frame => [rendererFilename(frame.id), frame]))
746
+ const frames: Array<{ png: string; id?: string; name?: string; index: number }> = []
747
+ try {
748
+ for (const [index, filename] of pngs.entries()) {
749
+ const out = await createRenderOutput()
750
+ await rename(join(outDir, filename), out)
751
+ const sourceFrame = sourceFrameByPng.get(filename)
752
+ frames.push({
753
+ png: out,
754
+ ...(sourceFrame?.id === undefined ? {} : { id: sourceFrame.id }),
755
+ ...(sourceFrame?.name === undefined ? {} : { name: sourceFrame.name }),
756
+ index,
757
+ })
758
+ }
759
+ } catch (error) {
760
+ await Promise.all(frames.map(frame => rm(frame.png, { force: true }).catch(() => {})))
761
+ throw error
762
+ }
763
+ return { png: frames[0]!.png, frames, warnings: [], stdout, stderr }
764
+ } finally {
765
+ clearTimeout(timeout)
766
+ options.signal.removeEventListener('abort', onAbort)
767
+ await rm(tempRoot, { recursive: true, force: true }).catch(() => {})
768
+ }
769
+ }
770
+
771
+ /**
772
+ * Purely enrich a canonical tool-result value with a browser render grant.
773
+ * Returns the value unchanged when no route/artifact exists.
774
+ */
775
+ export function projectRenderGrant(
776
+ value: JsonValue,
777
+ controller: RenderAccessController,
778
+ viewer?: ViewerGrant,
779
+ editor?: EditorGrant,
780
+ ): JsonValue {
781
+ if (!controller.routeAvailable || !isRecord(value)) return value
782
+ if (
783
+ typeof value.path !== 'string'
784
+ || typeof value.filename !== 'string'
785
+ || typeof value.bytes !== 'number'
786
+ ) return value
787
+ const result = value as unknown as RenderResult
788
+ const grantFor = (frame: RenderFrame, index?: number): RenderGrant => {
789
+ const token = controller.signArtifact({
790
+ kind: 'image',
791
+ filename: frame.filename,
792
+ bytes: frame.bytes,
793
+ sha256: frame.sha256,
794
+ })
795
+ return {
796
+ path: frame.path,
797
+ previewUrl: `${RENDER_ROUTE_PREFIX}/${token}`,
798
+ downloadUrl: `${RENDER_ROUTE_PREFIX}/${token}?download=1`,
799
+ width: frame.width,
800
+ height: frame.height,
801
+ ...(frame.id === undefined ? {} : { id: frame.id }),
802
+ ...(frame.name === undefined ? {} : { name: frame.name }),
803
+ ...(index === undefined ? {} : { index }),
804
+ }
805
+ }
806
+ const projectedFrames = result.frames?.map((frame, index) => grantFor(frame, index))
807
+ const primaryToken = controller.sign(result)
808
+ const grant: RenderGrant = projectedFrames?.[0] ?? {
809
+ path: result.path,
810
+ previewUrl: `${RENDER_ROUTE_PREFIX}/${primaryToken}`,
811
+ downloadUrl: `${RENDER_ROUTE_PREFIX}/${primaryToken}?download=1`,
812
+ ...(result.width === undefined ? {} : { width: result.width }),
813
+ ...(result.height === undefined ? {} : { height: result.height }),
814
+ }
815
+ let document: DocumentGrant | undefined
816
+ if (result.document !== undefined) {
817
+ const documentToken = controller.signArtifact({
818
+ kind: 'document',
819
+ filename: result.document.filename,
820
+ bytes: result.document.bytes,
821
+ sha256: result.document.sha256,
822
+ })
823
+ const url = `${RENDER_ROUTE_PREFIX}/${documentToken}`
824
+ document = {
825
+ ...result.document,
826
+ path: result.sourcePath ?? result.document.path,
827
+ url,
828
+ previewUrl: url,
829
+ downloadUrl: `${url}?download=1`,
830
+ }
831
+ }
832
+ const envelope = {
833
+ schemaVersion: projectedFrames === undefined ? 1 : 2,
834
+ image: grant,
835
+ ...(projectedFrames === undefined ? {} : { frames: projectedFrames }),
836
+ ...(document === undefined ? {} : { document }),
837
+ ...(result.sourcePath === undefined ? {} : { sourcePath: result.sourcePath }),
838
+ ...(result.renderer === undefined ? {} : { renderer: result.renderer }),
839
+ ...(result.rendererBinary === undefined ? {} : { rendererBinary: result.rendererBinary }),
840
+ ...(result.fidelity === undefined ? {} : { fidelity: result.fidelity }),
841
+ ...(result.warnings === undefined ? {} : { warnings: result.warnings }),
842
+ ...(viewer === undefined ? {} : { viewer }),
843
+ ...(editor === undefined ? {} : { editor }),
844
+ ...(editor === undefined || result.autoOpenEditor !== true ? {} : { autoOpenEditor: true }),
845
+ }
846
+ return { ...value, [PRESENTATION_META_KEY]: envelope } as unknown as JsonValue
847
+ }
848
+
849
+ /** Reserve a fresh output path inside the managed render directory. */
850
+ export async function createRenderOutput(): Promise<string> {
851
+ await mkdir(renderDir(), { recursive: true, mode: 0o700 })
852
+ return join(renderDir(), `render-${randomUUID()}.png`)
853
+ }
854
+
855
+ /** Stat + cap-check a freshly rendered PNG. */
856
+ export async function verifyRenderOutput(out: string): Promise<{
857
+ bytes: number
858
+ width: number
859
+ height: number
860
+ sha256: string
861
+ }> {
862
+ const info = await lstat(out)
863
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: renderer did not produce a regular file`)
864
+ if (info.size > MAX_RENDER_BYTES) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: rendered PNG exceeds ${MAX_RENDER_BYTES} bytes`)
865
+ if (info.size < 33) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: rendered PNG is truncated`)
866
+ const bytes = await readFile(out)
867
+ const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
868
+ if (!bytes.subarray(0, 8).equals(signature)) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: renderer output is not a PNG`)
869
+ if (bytes.readUInt32BE(8) !== 13 || bytes.toString('ascii', 12, 16) !== 'IHDR') {
870
+ throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: PNG has no valid IHDR`)
871
+ }
872
+ const width = bytes.readUInt32BE(16)
873
+ const height = bytes.readUInt32BE(20)
874
+ if (width === 0 || height === 0 || width > MAX_RENDER_DIMENSION || height > MAX_RENDER_DIMENSION) {
875
+ throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: rendered PNG dimensions are invalid`)
876
+ }
877
+ if (width * height > MAX_RENDER_PIXELS) throw new Error(`${OPENPENCIL_RENDER_TOOL_NAME}: rendered PNG has too many pixels`)
878
+ return {
879
+ bytes: info.size,
880
+ width,
881
+ height,
882
+ sha256: createHash('sha256').update(bytes).digest('hex'),
883
+ }
884
+ }