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