@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,634 @@
1
+ /**
2
+ * Safe recovery of browser-only presentation metadata for nested Code Mode
3
+ * tool calls.
4
+ *
5
+ * DSH persists a nested `openpencil_render` outcome as a
6
+ * `tool/code-dispatch` event, but published DSH through 0.1.0-rc.6 omits the native
7
+ * `tool/result` presentation metadata from that event. The browser can ask
8
+ * this same-origin endpoint to re-project that metadata without submitting
9
+ * any path or tool result of its own. Live results are remembered briefly so
10
+ * an explicitly requested editor grant can be restored; replayed durable
11
+ * events are preview-only.
12
+ *
13
+ * @module dsh-openpencil/presentation-hydration
14
+ */
15
+
16
+ import { createHash } from 'node:crypto'
17
+ import type { IncomingMessage, ServerResponse } from 'node:http'
18
+ import { basename, isAbsolute, join } from 'node:path'
19
+ import { SessionId, type SessionStore } from '@deepseek-ai/dsh-session'
20
+ import type { JsonValue, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
21
+ import { isLoopbackRemoteAddress, type EditorHostController } from './editor-host.js'
22
+ import {
23
+ MAX_DOCUMENT_BYTES,
24
+ MAX_RENDER_BYTES,
25
+ PRESENTATION_META_KEY,
26
+ RenderAccessController,
27
+ projectRenderGrant,
28
+ renderDir,
29
+ snapshotDir,
30
+ type RenderFrame,
31
+ type RenderResult,
32
+ } from './renderer.js'
33
+ import { OPENPENCIL_RENDER_TOOL_NAME } from './tool-names.js'
34
+ import type { ViewerAssetController } from './viewer-assets.js'
35
+
36
+ /** Exact same-origin endpoint used by the client to recover nested metadata. */
37
+ export const PRESENTATION_HYDRATION_ROUTE = '/_dsh/dsh-openpencil/presentation'
38
+
39
+ const SHA256_PATTERN = /^[a-f0-9]{64}$/
40
+ const RENDER_FILENAME_PATTERN = /^render-[A-Za-z0-9-]+\.png$/
41
+ const MAX_BODY_BYTES = 4 * 1024
42
+ const MAX_HISTORY_TEXT_BYTES = 1024 * 1024
43
+ const MAX_HYDRATION_RESPONSE_BYTES = 4 * 1024 * 1024
44
+ const DEFAULT_TTL_MS = 15 * 60 * 1000
45
+ const DEFAULT_MAX_ENTRIES = 256
46
+ const DEFAULT_MAX_RECORD_BYTES = 32 * 1024
47
+ const DEFAULT_MAX_BYTES = 2 * 1024 * 1024
48
+ const MAX_HYDRATION_FRAMES = 128
49
+
50
+ interface HydrationRequest {
51
+ sessionId: string
52
+ callId: string
53
+ documentSha256: string
54
+ }
55
+
56
+ interface LiveAuthorizationRecord {
57
+ kind: 'authorization'
58
+ expiresAt: number
59
+ bytes: number
60
+ documentSha256: string
61
+ sourcePath?: string
62
+ editable: boolean
63
+ resultDigest: string
64
+ }
65
+
66
+ interface LiveTombstoneRecord {
67
+ kind: 'tombstone'
68
+ expiresAt: number
69
+ bytes: 1
70
+ }
71
+
72
+ type LiveRecord = LiveAuthorizationRecord | LiveTombstoneRecord
73
+
74
+ type StoredSession = NonNullable<ReturnType<SessionStore['get']>>
75
+ type StoredEvent = StoredSession['events'][number]
76
+
77
+ interface HistoricalSettlement {
78
+ duplicate: boolean
79
+ event?: StoredEvent
80
+ }
81
+
82
+ interface SessionHistoryIndex {
83
+ indexedLength: number
84
+ indexedTail?: StoredEvent
85
+ settlements: Map<string, HistoricalSettlement>
86
+ }
87
+
88
+ interface PresentationHydrationOptions {
89
+ ttlMs?: number
90
+ maxEntries?: number
91
+ maxRecordBytes?: number
92
+ maxBytes?: number
93
+ now?: () => number
94
+ }
95
+
96
+ interface PresentationHydrationDependencies {
97
+ sessions: Pick<SessionStore, 'get'>
98
+ render: RenderAccessController
99
+ viewer?: Pick<ViewerAssetController, 'viewerGrant'>
100
+ editor?: Pick<EditorHostController, 'grantFor'>
101
+ /** DSH Web authorities derived from `webRuntime.trustedHosts`. */
102
+ trustedHosts?: readonly string[] | (() => readonly string[])
103
+ }
104
+
105
+ class HttpError extends Error {
106
+ constructor(readonly status: number) {
107
+ super(`presentation hydration failed (${status})`)
108
+ }
109
+ }
110
+
111
+ function isRecord(value: unknown): value is Record<string, unknown> {
112
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
113
+ }
114
+
115
+ function hasExactKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>): boolean {
116
+ return Object.keys(value).every(key => allowed.has(key))
117
+ }
118
+
119
+ function isSafeString(value: unknown, maxLength = 16 * 1024): value is string {
120
+ return typeof value === 'string' && value.length > 0 && value.length <= maxLength && !/[\u0000-\u001f\u007f]/.test(value)
121
+ }
122
+
123
+ function isSafeInteger(value: unknown, min: number, max: number): value is number {
124
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= min && value <= max
125
+ }
126
+
127
+ function isSha256(value: unknown): value is string {
128
+ return typeof value === 'string' && SHA256_PATTERN.test(value)
129
+ }
130
+
131
+ function isManagedArtifactPath(path: unknown, root: string, filename: string): path is string {
132
+ return typeof path === 'string'
133
+ && isAbsolute(path)
134
+ && basename(path) === filename
135
+ && path === join(root, filename)
136
+ }
137
+
138
+ const FRAME_KEYS = new Set([
139
+ 'path', 'filename', 'mimeType', 'bytes', 'width', 'height', 'sha256',
140
+ 'id', 'name', 'index',
141
+ ])
142
+
143
+ function parseFrame(value: unknown, expectedIndex: number): RenderFrame | undefined {
144
+ if (!isRecord(value) || !hasExactKeys(value, FRAME_KEYS)) return undefined
145
+ if (
146
+ typeof value.filename !== 'string'
147
+ || !RENDER_FILENAME_PATTERN.test(value.filename)
148
+ || !isManagedArtifactPath(value.path, renderDir(), value.filename)
149
+ || value.mimeType !== 'image/png'
150
+ || !isSafeInteger(value.bytes, 1, MAX_RENDER_BYTES)
151
+ || !isSafeInteger(value.width, 1, 32_768)
152
+ || !isSafeInteger(value.height, 1, 32_768)
153
+ || value.width * value.height > 128 * 1024 * 1024
154
+ || !isSha256(value.sha256)
155
+ || (value.id !== undefined && !isSafeString(value.id, 512))
156
+ || (value.name !== undefined && !isSafeString(value.name, 512))
157
+ || (value.index !== undefined && value.index !== expectedIndex)
158
+ ) return undefined
159
+ return value as unknown as RenderFrame
160
+ }
161
+
162
+ const DOCUMENT_KEYS = new Set(['path', 'filename', 'mimeType', 'bytes', 'sha256'])
163
+
164
+ function isDocumentSnapshot(value: unknown): value is NonNullable<RenderResult['document']> {
165
+ if (!isRecord(value) || !hasExactKeys(value, DOCUMENT_KEYS)) return false
166
+ return isSha256(value.sha256)
167
+ && value.filename === `${value.sha256}.op`
168
+ && isManagedArtifactPath(value.path, snapshotDir(), value.filename)
169
+ && value.mimeType === 'application/json'
170
+ && isSafeInteger(value.bytes, 1, MAX_DOCUMENT_BYTES)
171
+ }
172
+
173
+ const RESULT_KEYS = new Set([
174
+ 'path', 'filename', 'mimeType', 'kind', 'description', 'sourceTool',
175
+ 'previewIntent', 'bytes', 'width', 'height', 'sha256', 'sourcePath',
176
+ 'renderer', 'rendererBinary', 'fidelity', 'warnings', 'frames',
177
+ 'frameCount', 'editable', 'document', 'note',
178
+ 'autoOpenEditor',
179
+ ])
180
+
181
+ /**
182
+ * Accept only the canonical, content-addressed result shape emitted by this
183
+ * plugin. Hydration never signs a legacy absolute-path-only render.
184
+ */
185
+ export function parseHydratableRenderResult(value: unknown): RenderResult | undefined {
186
+ if (!isRecord(value) || !hasExactKeys(value, RESULT_KEYS)) return undefined
187
+ if (
188
+ typeof value.filename !== 'string'
189
+ || !RENDER_FILENAME_PATTERN.test(value.filename)
190
+ || !isManagedArtifactPath(value.path, renderDir(), value.filename)
191
+ || value.mimeType !== 'image/png'
192
+ || value.kind !== 'image'
193
+ || !isSafeString(value.description)
194
+ || value.sourceTool !== OPENPENCIL_RENDER_TOOL_NAME
195
+ || value.previewIntent !== 'image'
196
+ || !isSafeInteger(value.bytes, 1, MAX_RENDER_BYTES)
197
+ || !isSha256(value.sha256)
198
+ || !isDocumentSnapshot(value.document)
199
+ || (value.width !== undefined && !isSafeInteger(value.width, 1, 32_768))
200
+ || (value.height !== undefined && !isSafeInteger(value.height, 1, 32_768))
201
+ || (value.width !== undefined && value.height !== undefined && value.width * value.height > 128 * 1024 * 1024)
202
+ || (value.sourcePath !== undefined && (!isSafeString(value.sourcePath) || !isAbsolute(value.sourcePath) || !value.sourcePath.toLowerCase().endsWith('.op')))
203
+ || (value.renderer !== undefined && value.renderer !== 'openpencil' && value.renderer !== 'jian')
204
+ || (value.rendererBinary !== undefined && !isSafeString(value.rendererBinary))
205
+ || (value.fidelity !== undefined && value.fidelity !== 'exact' && value.fidelity !== 'runtime-preview')
206
+ || (value.editable !== undefined && typeof value.editable !== 'boolean')
207
+ || (value.autoOpenEditor !== undefined && typeof value.autoOpenEditor !== 'boolean')
208
+ || (value.note !== undefined && !isSafeString(value.note))
209
+ ) return undefined
210
+
211
+ if (value.warnings !== undefined) {
212
+ if (!Array.isArray(value.warnings) || value.warnings.length > 32 || !value.warnings.every(item => typeof item === 'string' && item.length <= 1_024)) {
213
+ return undefined
214
+ }
215
+ }
216
+
217
+ let frames: RenderFrame[] | undefined
218
+ if (value.frames !== undefined) {
219
+ if (!Array.isArray(value.frames) || value.frames.length === 0 || value.frames.length > MAX_HYDRATION_FRAMES) return undefined
220
+ frames = []
221
+ for (const [index, candidate] of value.frames.entries()) {
222
+ const frame = parseFrame(candidate, index)
223
+ if (frame === undefined) return undefined
224
+ frames.push(frame)
225
+ }
226
+ if (
227
+ frames[0]?.path !== value.path
228
+ || frames[0]?.filename !== value.filename
229
+ || frames[0]?.bytes !== value.bytes
230
+ || frames[0]?.sha256 !== value.sha256
231
+ ) return undefined
232
+ }
233
+ if (value.frameCount !== undefined && (!isSafeInteger(value.frameCount, 1, MAX_HYDRATION_FRAMES) || value.frameCount !== frames?.length)) return undefined
234
+ if (frames === undefined && value.frameCount !== undefined) return undefined
235
+
236
+ return value as unknown as RenderResult
237
+ }
238
+
239
+ function canonicalJson(value: unknown): string {
240
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'
241
+ if (Array.isArray(value)) return `[${value.map(item => canonicalJson(item)).join(',')}]`
242
+ const record = value as Record<string, unknown>
243
+ const members = Object.keys(record)
244
+ .filter(key => record[key] !== undefined)
245
+ .sort()
246
+ .map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
247
+ return `{${members.join(',')}}`
248
+ }
249
+
250
+ function renderResultDigest(result: RenderResult): string {
251
+ return createHash('sha256').update(canonicalJson(result)).digest('hex')
252
+ }
253
+
254
+ function parseHydrationRequest(value: unknown): HydrationRequest | undefined {
255
+ if (!isRecord(value)) return undefined
256
+ const expected = new Set(['sessionId', 'callId', 'documentSha256'])
257
+ if (Object.keys(value).length !== expected.size || !hasExactKeys(value, expected)) return undefined
258
+ if (
259
+ !isSafeString(value.sessionId, 256)
260
+ || !isSafeString(value.callId, 512)
261
+ || !isSha256(value.documentSha256)
262
+ ) return undefined
263
+ return value as unknown as HydrationRequest
264
+ }
265
+
266
+ function parseAuthority(authority: string): URL | undefined {
267
+ try {
268
+ return new URL(`http://${authority}`)
269
+ } catch {
270
+ return undefined
271
+ }
272
+ }
273
+
274
+ function canonicalAuthority(entry: string, entryUrl: URL): string {
275
+ const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
276
+ return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
277
+ }
278
+
279
+ function isLoopbackHostname(hostname: string): boolean {
280
+ if (hostname === 'localhost' || hostname === '[::1]') return true
281
+ const parts = hostname.split('.')
282
+ return parts.length === 4
283
+ && parts[0] === '127'
284
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
285
+ }
286
+
287
+ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
288
+ return trustedHosts.some(entry => {
289
+ const entryUrl = parseAuthority(entry)
290
+ if (entryUrl === undefined) return false
291
+ return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
292
+ ? entryUrl.hostname === hostUrl.hostname
293
+ : entryUrl.host === hostUrl.host
294
+ })
295
+ }
296
+
297
+ /** Mirror DSH's Host / Fetch-Metadata / Origin browser-trust fence. */
298
+ function requestAuthority(req: IncomingMessage, trustedHosts: readonly string[]): { editorAllowed: boolean } | undefined {
299
+ const host = req.headers.host
300
+ if (typeof host !== 'string') return undefined
301
+ const hostUrl = parseAuthority(host)
302
+ if (hostUrl === undefined) return undefined
303
+ const loopback = isLoopbackHostname(hostUrl.hostname)
304
+ if (!loopback && !isTrustedAuthority(hostUrl, trustedHosts)) return undefined
305
+ if (req.headers['sec-fetch-site'] === 'cross-site') return undefined
306
+
307
+ const rawOrigin = req.headers.origin
308
+ if (rawOrigin !== undefined) {
309
+ if (typeof rawOrigin !== 'string') return undefined
310
+ try {
311
+ if (new URL(rawOrigin).host !== hostUrl.host) return undefined
312
+ } catch {
313
+ return undefined
314
+ }
315
+ }
316
+ return { editorAllowed: loopback && isLoopbackRemoteAddress(req.socket.remoteAddress) }
317
+ }
318
+
319
+ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
320
+ const contentType = req.headers['content-type']
321
+ if (typeof contentType !== 'string' || !/^application\/json(?:\s*;|$)/i.test(contentType)) throw new HttpError(415)
322
+ const contentLength = req.headers['content-length']
323
+ if (typeof contentLength === 'string') {
324
+ const declared = Number(contentLength)
325
+ if (!Number.isSafeInteger(declared) || declared < 0) throw new HttpError(400)
326
+ if (declared > MAX_BODY_BYTES) throw new HttpError(413)
327
+ }
328
+ const chunks: Buffer[] = []
329
+ let size = 0
330
+ for await (const chunk of req) {
331
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
332
+ size += bytes.length
333
+ if (size > MAX_BODY_BYTES) throw new HttpError(413)
334
+ chunks.push(bytes)
335
+ }
336
+ if (size === 0) throw new HttpError(400)
337
+ try {
338
+ return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown
339
+ } catch {
340
+ throw new HttpError(400)
341
+ }
342
+ }
343
+
344
+ function setSecurityHeaders(res: ServerResponse): void {
345
+ res.setHeader('Cache-Control', 'private, no-store, max-age=0')
346
+ res.setHeader('Pragma', 'no-cache')
347
+ res.setHeader('X-Content-Type-Options', 'nosniff')
348
+ res.setHeader('Referrer-Policy', 'no-referrer')
349
+ res.setHeader('Cross-Origin-Resource-Policy', 'same-origin')
350
+ }
351
+
352
+ function finishEmpty(res: ServerResponse, status: number): void {
353
+ setSecurityHeaders(res)
354
+ res.writeHead(status)
355
+ res.end()
356
+ }
357
+
358
+ function finishJson(res: ServerResponse, value: JsonValue): void {
359
+ const body = JSON.stringify(value)
360
+ if (Buffer.byteLength(body) > MAX_HYDRATION_RESPONSE_BYTES) {
361
+ finishEmpty(res, 413)
362
+ return
363
+ }
364
+ setSecurityHeaders(res)
365
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
366
+ res.setHeader('Content-Length', String(Buffer.byteLength(body)))
367
+ res.writeHead(200)
368
+ res.end(body)
369
+ }
370
+
371
+ function recordKey(sessionId: string, callId: string): string {
372
+ return `${sessionId.length}:${sessionId}${callId}`
373
+ }
374
+
375
+ /** TTL/LRU cache plus fail-closed hydration endpoint. */
376
+ export class PresentationHydrationController {
377
+ readonly #ttlMs: number
378
+ readonly #maxEntries: number
379
+ readonly #maxRecordBytes: number
380
+ readonly #maxBytes: number
381
+ readonly #now: () => number
382
+ readonly #trustedHosts: () => readonly string[]
383
+ readonly #live = new Map<string, LiveRecord>()
384
+ readonly #history = new WeakMap<object, SessionHistoryIndex>()
385
+ #liveBytes = 0
386
+
387
+ constructor(
388
+ private readonly dependencies: PresentationHydrationDependencies,
389
+ options: PresentationHydrationOptions = {},
390
+ ) {
391
+ this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS
392
+ this.#maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES
393
+ this.#maxRecordBytes = options.maxRecordBytes ?? DEFAULT_MAX_RECORD_BYTES
394
+ this.#maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES
395
+ this.#now = options.now ?? Date.now
396
+ const trustedHosts = dependencies.trustedHosts
397
+ this.#trustedHosts = typeof trustedHosts === 'function'
398
+ ? trustedHosts
399
+ : () => trustedHosts ?? []
400
+ if (!Number.isSafeInteger(this.#ttlMs) || this.#ttlMs <= 0) throw new TypeError('presentation hydration ttlMs must be positive')
401
+ if (!Number.isSafeInteger(this.#maxEntries) || this.#maxEntries <= 0) throw new TypeError('presentation hydration maxEntries must be positive')
402
+ if (!Number.isSafeInteger(this.#maxRecordBytes) || this.#maxRecordBytes <= 0) throw new TypeError('presentation hydration maxRecordBytes must be positive')
403
+ if (!Number.isSafeInteger(this.#maxBytes) || this.#maxBytes <= 0) throw new TypeError('presentation hydration maxBytes must be positive')
404
+ }
405
+
406
+ /** Observe one trusted in-process result before Code Mode drops its meta. */
407
+ observeToolResult(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): void {
408
+ if (
409
+ exec.name !== OPENPENCIL_RENDER_TOOL_NAME
410
+ || exec.parent === undefined
411
+ || result.isError
412
+ || exec.agent === undefined
413
+ ) return
414
+ const parsed = parseHydratableRenderResult(result.value)
415
+ if (parsed === undefined) return
416
+ const sessionId = String(exec.agent.session.id)
417
+ const callId = String(exec.callId)
418
+ if (!isSafeString(sessionId, 256) || !isSafeString(callId, 512)) return
419
+ const now = this.#now()
420
+ this.#prune(now)
421
+ const key = recordKey(sessionId, callId)
422
+ const existing = this.#live.get(key)
423
+ if (existing !== undefined) {
424
+ // A tool settlement identity is single-use. Any second observation is
425
+ // ambiguous even when its content hash matches: discard the retained
426
+ // result and remember a fail-closed tombstone instead of overwriting it.
427
+ if (existing.kind === 'authorization') {
428
+ this.#deleteLive(key)
429
+ this.#live.set(key, { kind: 'tombstone', expiresAt: now + this.#ttlMs, bytes: 1 })
430
+ this.#liveBytes += 1
431
+ } else {
432
+ existing.expiresAt = now + this.#ttlMs
433
+ this.#touchLive(key, existing)
434
+ }
435
+ this.#enforceBounds()
436
+ return
437
+ }
438
+
439
+ const authorization: LiveAuthorizationRecord = {
440
+ kind: 'authorization',
441
+ expiresAt: now + this.#ttlMs,
442
+ bytes: 0,
443
+ documentSha256: parsed.document!.sha256,
444
+ sourcePath: parsed.sourcePath,
445
+ editable: parsed.editable === true,
446
+ resultDigest: renderResultDigest(parsed),
447
+ }
448
+ const bytes = Buffer.byteLength(JSON.stringify(authorization), 'utf8')
449
+ if (bytes > this.#maxRecordBytes || bytes > this.#maxBytes) {
450
+ this.#live.set(key, { kind: 'tombstone', expiresAt: now + this.#ttlMs, bytes: 1 })
451
+ this.#liveBytes += 1
452
+ this.#enforceBounds()
453
+ return
454
+ }
455
+ authorization.bytes = bytes
456
+ this.#live.set(key, authorization)
457
+ this.#liveBytes += bytes
458
+ this.#enforceBounds()
459
+ }
460
+
461
+ /** Drop all live editor-capable records owned by a disposed session. */
462
+ forgetSession(sessionId: string): void {
463
+ if (!isSafeString(sessionId, 256)) return
464
+ const prefix = `${sessionId.length}:${sessionId}`
465
+ for (const key of this.#live.keys()) {
466
+ if (key.startsWith(prefix)) this.#deleteLive(key)
467
+ }
468
+ }
469
+
470
+ /** Handle the exact same-origin POST hydration route. */
471
+ async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
472
+ try {
473
+ const url = new URL(req.url ?? '/', 'http://dsh.invalid')
474
+ if (url.pathname !== PRESENTATION_HYDRATION_ROUTE || url.search !== '') throw new HttpError(404)
475
+ if (req.method !== 'POST') {
476
+ res.setHeader('Allow', 'POST')
477
+ throw new HttpError(405)
478
+ }
479
+ const authority = requestAuthority(req, this.#trustedHosts())
480
+ if (authority === undefined) throw new HttpError(403)
481
+ const request = parseHydrationRequest(await readJsonBody(req))
482
+ if (request === undefined) throw new HttpError(400)
483
+ const resolved = this.#resolve(request, authority.editorAllowed)
484
+ if (resolved === undefined) throw new HttpError(404)
485
+ finishJson(res, resolved)
486
+ } catch (error) {
487
+ const incomplete = !req.complete
488
+ if (incomplete) res.setHeader('Connection', 'close')
489
+ finishEmpty(res, error instanceof HttpError ? error.status : 500)
490
+ // Rejected requests may still carry an unread body. Do not let an
491
+ // oversized/cross-site uploader pin this exact route until keep-alive
492
+ // timeout; mirror DSH's bounded HTTP bridge teardown.
493
+ if (incomplete) req.destroy()
494
+ }
495
+ }
496
+
497
+ #resolve(request: HydrationRequest, editorAllowed: boolean): JsonValue | undefined {
498
+ // The durable settlement is the sole preview authority. The live cache
499
+ // can only restore editing after it matches that event exactly.
500
+ const result = this.#historicalResult(request)
501
+ if (result === undefined) return undefined
502
+ const authorization = this.#liveAuthorization(request, result)
503
+ if (authorization === null) return undefined
504
+ const editor = authorization !== undefined
505
+ && editorAllowed
506
+ && authorization.editable
507
+ && authorization.sourcePath !== undefined
508
+ ? this.dependencies.editor?.grantFor(authorization.sourcePath, authorization.documentSha256)
509
+ : undefined
510
+ const projected = projectRenderGrant(
511
+ result as unknown as JsonValue,
512
+ this.dependencies.render,
513
+ this.dependencies.viewer?.viewerGrant,
514
+ editor,
515
+ )
516
+ if (!isRecord(projected) || !(PRESENTATION_META_KEY in projected)) return undefined
517
+ const envelope = projected[PRESENTATION_META_KEY]
518
+ if (!isRecord(envelope)) return undefined
519
+ return { [PRESENTATION_META_KEY]: envelope } as unknown as JsonValue
520
+ }
521
+
522
+ #liveAuthorization(
523
+ request: HydrationRequest,
524
+ result: RenderResult,
525
+ ): LiveAuthorizationRecord | null | undefined {
526
+ const now = this.#now()
527
+ this.#prune(now)
528
+ const key = recordKey(request.sessionId, request.callId)
529
+ const record = this.#live.get(key)
530
+ if (record === undefined || record.expiresAt <= now) {
531
+ this.#deleteLive(key)
532
+ return undefined
533
+ }
534
+ if (record.kind === 'tombstone') {
535
+ this.#touchLive(key, record)
536
+ return null
537
+ }
538
+ if (
539
+ record.documentSha256 !== request.documentSha256
540
+ || record.sourcePath !== result.sourcePath
541
+ || record.editable !== (result.editable === true)
542
+ || record.resultDigest !== renderResultDigest(result)
543
+ ) return undefined
544
+ this.#touchLive(key, record)
545
+ return record
546
+ }
547
+
548
+ #historicalResult(request: HydrationRequest): RenderResult | undefined {
549
+ const session = this.dependencies.sessions.get(SessionId(request.sessionId))
550
+ if (session === undefined) return undefined
551
+ const index = this.#historyIndex(session)
552
+ const settlement = index.settlements.get(request.callId)
553
+ if (settlement === undefined || settlement.duplicate || settlement.event === undefined) return undefined
554
+
555
+ const result = this.#parseHistoricalEvent(settlement.event)
556
+ return result?.document?.sha256 === request.documentSha256 ? result : undefined
557
+ }
558
+
559
+ #historyIndex(session: StoredSession): SessionHistoryIndex {
560
+ const events = session.events
561
+ let index = this.#history.get(session as object)
562
+ if (
563
+ index === undefined
564
+ || events.length < index.indexedLength
565
+ || (index.indexedLength > 0 && events[index.indexedLength - 1] !== index.indexedTail)
566
+ ) {
567
+ index = {
568
+ indexedLength: 0,
569
+ settlements: new Map(),
570
+ }
571
+ this.#history.set(session as object, index)
572
+ }
573
+
574
+ // Session events are append-only. Scan only the suffix added since the
575
+ // last hydration; replacement, truncation, or tail mutation resets safely.
576
+ for (let eventIndex = index.indexedLength; eventIndex < events.length; eventIndex += 1) {
577
+ const event = events[eventIndex]
578
+ if (event?.type !== 'tool/code-dispatch' || event.data.name !== OPENPENCIL_RENDER_TOOL_NAME) continue
579
+ const callId = String(event.data.subCallId)
580
+ if (!isSafeString(callId, 512)) continue
581
+ const existing = index.settlements.get(callId)
582
+ if (existing === undefined) {
583
+ index.settlements.set(callId, { duplicate: false, event })
584
+ } else if (!existing.duplicate) {
585
+ index.settlements.set(callId, { duplicate: true })
586
+ }
587
+ }
588
+ index.indexedLength = events.length
589
+ index.indexedTail = events[events.length - 1]
590
+ return index
591
+ }
592
+
593
+ #parseHistoricalEvent(event: StoredEvent): RenderResult | undefined {
594
+ if (event.type !== 'tool/code-dispatch') return undefined
595
+ const data = event.data
596
+ if (data.isError !== false || !Array.isArray(data.content) || data.content.length !== 1) return undefined
597
+ const block = data.content[0]
598
+ if (!isRecord(block) || Object.keys(block).length !== 2 || block.type !== 'text' || typeof block.text !== 'string') return undefined
599
+ if (block.text.length > MAX_HISTORY_TEXT_BYTES || Buffer.byteLength(block.text, 'utf8') > MAX_HISTORY_TEXT_BYTES) return undefined
600
+ let parsedValue: unknown
601
+ try {
602
+ parsedValue = JSON.parse(block.text) as unknown
603
+ } catch {
604
+ return undefined
605
+ }
606
+ return parseHydratableRenderResult(parsedValue)
607
+ }
608
+
609
+ #prune(now: number): void {
610
+ for (const [key, record] of this.#live) {
611
+ if (record.expiresAt <= now) this.#deleteLive(key)
612
+ }
613
+ }
614
+
615
+ #touchLive(key: string, record: LiveRecord): void {
616
+ this.#live.delete(key)
617
+ this.#live.set(key, record)
618
+ }
619
+
620
+ #deleteLive(key: string): void {
621
+ const record = this.#live.get(key)
622
+ if (record === undefined) return
623
+ this.#live.delete(key)
624
+ this.#liveBytes -= record.bytes
625
+ }
626
+
627
+ #enforceBounds(): void {
628
+ while (this.#live.size > this.#maxEntries || this.#liveBytes > this.#maxBytes) {
629
+ const oldest = this.#live.keys().next().value as string | undefined
630
+ if (oldest === undefined) break
631
+ this.#deleteLive(oldest)
632
+ }
633
+ }
634
+ }