@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.
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/THIRD_PARTY_NOTICES.md +69 -0
- package/cordis.patch.yml +6 -0
- package/docs/images/dsh-openpencil-overview.png +0 -0
- package/lib/client/details-compat.d.ts +28 -0
- package/lib/client/details-compat.js +10 -0
- package/lib/client/editor-bridge.d.ts +84 -0
- package/lib/client/editor-bridge.js +127 -0
- package/lib/client/editor-dock-layout.d.ts +15 -0
- package/lib/client/editor-dock-layout.js +43 -0
- package/lib/client/editor-recovery.d.ts +27 -0
- package/lib/client/editor-recovery.js +83 -0
- package/lib/client/editor-successor.d.ts +28 -0
- package/lib/client/editor-successor.js +109 -0
- package/lib/client/presentation-hydration.d.ts +36 -0
- package/lib/client/presentation-hydration.js +168 -0
- package/lib/client/selection-polling.d.ts +24 -0
- package/lib/client/selection-polling.js +79 -0
- package/lib/client/selection-store.d.ts +26 -0
- package/lib/client/selection-store.js +85 -0
- package/lib/client.js +4033 -0
- package/lib/design-tools.d.ts +21 -0
- package/lib/design-tools.js +214 -0
- package/lib/editor-host.d.ts +62 -0
- package/lib/editor-host.js +1147 -0
- package/lib/editor-recovery.d.ts +38 -0
- package/lib/editor-recovery.js +319 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +132 -0
- package/lib/mcp-client.d.ts +47 -0
- package/lib/mcp-client.js +148 -0
- package/lib/new-tool.d.ts +23 -0
- package/lib/new-tool.js +136 -0
- package/lib/presentation-hydration.d.ts +55 -0
- package/lib/presentation-hydration.js +561 -0
- package/lib/renderer.d.ts +203 -0
- package/lib/renderer.js +772 -0
- package/lib/tool-names.d.ts +13 -0
- package/lib/tool-names.js +19 -0
- package/lib/tool.d.ts +27 -0
- package/lib/tool.js +221 -0
- package/lib/types/client/details-compat.d.ts +28 -0
- package/lib/types/client/editor-bridge.d.ts +84 -0
- package/lib/types/client/editor-dock-layout.d.ts +15 -0
- package/lib/types/client/editor-modal.d.ts +54 -0
- package/lib/types/client/editor-panel.d.ts +127 -0
- package/lib/types/client/editor-recovery.d.ts +27 -0
- package/lib/types/client/editor-successor.d.ts +28 -0
- package/lib/types/client/editor-workbench-host.d.ts +43 -0
- package/lib/types/client/frame-gallery.d.ts +98 -0
- package/lib/types/client/index.d.ts +176 -0
- package/lib/types/client/presentation-hydration.d.ts +36 -0
- package/lib/types/client/selection-dock.d.ts +18 -0
- package/lib/types/client/selection-polling.d.ts +24 -0
- package/lib/types/client/selection-store.d.ts +26 -0
- package/lib/types/tool-names.d.ts +13 -0
- package/lib/viewer-assets/canvaskit/canvaskit.js +288 -0
- package/lib/viewer-assets/canvaskit/canvaskit.wasm +0 -0
- package/lib/viewer-assets/manifest.json +22 -0
- package/lib/viewer-assets/op_web_sdk_bg.wasm +0 -0
- package/lib/viewer-assets/sdk.js +225 -0
- package/lib/viewer-assets.d.ts +74 -0
- package/lib/viewer-assets.js +303 -0
- package/package.json +95 -0
- package/scripts/build-client.mjs +27 -0
- package/scripts/run-tool.mjs +63 -0
- package/scripts/sync-viewer-assets.mjs +304 -0
- package/scripts/test-host.mjs +311 -0
- package/scripts/test-viewer-assets.mjs +54 -0
- package/src/client/details-compat.ts +44 -0
- package/src/client/editor-bridge.ts +170 -0
- package/src/client/editor-dock-layout.ts +56 -0
- package/src/client/editor-modal.tsx +607 -0
- package/src/client/editor-panel.tsx +823 -0
- package/src/client/editor-recovery.ts +116 -0
- package/src/client/editor-successor.ts +136 -0
- package/src/client/editor-workbench-host.tsx +280 -0
- package/src/client/frame-gallery.tsx +546 -0
- package/src/client/index.tsx +987 -0
- package/src/client/presentation-hydration.ts +210 -0
- package/src/client/react-dom-compat.d.ts +22 -0
- package/src/client/selection-dock.tsx +89 -0
- package/src/client/selection-polling.ts +93 -0
- package/src/client/selection-store.ts +108 -0
- package/src/design-tools.ts +242 -0
- package/src/editor-host.ts +1238 -0
- package/src/editor-recovery.ts +374 -0
- package/src/index.ts +219 -0
- package/src/mcp-client.ts +193 -0
- package/src/new-tool.ts +160 -0
- package/src/presentation-hydration.ts +634 -0
- package/src/renderer.ts +884 -0
- package/src/tool-names.ts +21 -0
- package/src/tool.ts +258 -0
- 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
|
+
}
|