lavish-axi 0.1.49 → 0.1.51
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/README.md +7 -1
- package/dist/chrome-client.js +315 -9
- package/dist/chrome.css +30 -0
- package/dist/cli.mjs +1482 -242
- package/package.json +1 -1
- package/plugin.json +1 -1
package/README.md
CHANGED
|
@@ -203,6 +203,12 @@ pnpm link
|
|
|
203
203
|
Agent-initiated ends keep reopening normally, same as before.
|
|
204
204
|
`lavish-axi poll`'s `ended` response and the `feedback` response for the final batch before an end both carry `next_step` guidance telling the agent to stop polling and deliver remaining updates in chat instead of reopening.
|
|
205
205
|
- **Precise targets** - Text annotations include selected text plus range anchors, so agents are not limited to whole-element selectors.
|
|
206
|
+
- **Image attachments** - Attach reference images (PNG, JPEG, WebP) to an annotation by pasting, drag-dropping, or using the annotation card's **Attach image** picker; each shows a thumbnail chip with upload, remove, retry, and error states.
|
|
207
|
+
Images are stored under the state dir and the queued prompt carries a server-generated absolute `path` and content-hash `id` (plus mime and dimensions) - never the raw bytes - so `lavish-axi poll` hands the agent a local file path to open.
|
|
208
|
+
Limits are `LAVISH_AXI_MAX_ATTACHMENT_BYTES` (default 10 MiB per image), `LAVISH_AXI_MAX_ATTACHMENTS_PER_PROMPT` (default 4), and `LAVISH_AXI_MAX_PROMPT_ATTACHMENT_BYTES` (default 25 MiB per annotation); if any image is missing or any annotation breaches a count or byte cap, the entire send batch is rejected, the queue is preserved, and the reason is surfaced in the composer rather than silently dropping images.
|
|
209
|
+
As a browser-side abuse guard, each chrome page allows 30 upload attempts per rolling minute, 4 uploads in flight at once, and 256 MiB of attempted image bytes over its lifetime; rejected uploads stay visible on their annotation card for retry or removal.
|
|
210
|
+
Attachments are cleaned up by `LAVISH_AXI_ATTACHMENT_TTL_MS` (default 7 days; `0`/`off` disables) but only once no pending prompt still references them; `LAVISH_AXI_MAX_ATTACHMENT_DISK_MB` (default 512 MiB; `0`/`off` disables) caps total attachment disk.
|
|
211
|
+
The cap is enforced when an image is uploaded, not just periodically: the upload first reclaims unreferenced files (oldest first, and never one added within the last hour), and if it still would not fit, that upload is refused with a storage-full error on its chip instead of discarding an image an annotation is about to send.
|
|
206
212
|
- **Mermaid diagrams** - In the Lavish browser, every rendered Mermaid diagram in a `.mermaid` container becomes an embedded editable Excalidraw whiteboard.
|
|
207
213
|
Click a diagram to unlock editing, and use its Fullscreen action to edit it over the whole viewport.
|
|
208
214
|
Whiteboard scenes autosave locally.
|
|
@@ -217,7 +223,7 @@ pnpm link
|
|
|
217
223
|
- **Diagnostic viewports** - `LAVISH_AXI_DIAGNOSTIC_VIEWPORTS` sets which viewport classes the layout-issue inbox tracks (`mobile`, `compact`, `desktop`; comma-separated, default all). Warnings whose class leaves the set are marked obsolete with an explicit reason instead of silently reading as fixed.
|
|
218
224
|
- **Server port** - Set `LAVISH_AXI_PORT` to choose the server port; it defaults to `4387`.
|
|
219
225
|
- **Network binding** - The server binds to loopback (`127.0.0.1`) by default. Set `LAVISH_AXI_HOST` to bind elsewhere; a wildcard (`0.0.0.0` or `::`) binds every interface. Binding beyond loopback exposes an unauthenticated server that can read and serve arbitrary local files to anything that can reach it, so only do so on a trusted network. Set `LAVISH_AXI_LINK_HOST` to control the hostname written into generated session links (defaults to the bind address, or loopback when bound to a wildcard).
|
|
220
|
-
- **Allowed hosts** - To defend against DNS rebinding, the server rejects (`403`) any request whose `Host` header is missing or not one it answers to: the loopback names (`127.0.0.1`, `::1`, `localhost`) plus the configured bind and link host. If you reach the server under another name - a wildcard bind accessed by LAN IP, a reverse-proxy hostname, or an extra interface - list those names in `LAVISH_AXI_ALLOWED_HOSTS` (whitespace-separated) to allow them. Behind a reverse proxy, the forwarded `X-Forwarded-Host` is validated against the same list, so add your public hostname there and have the proxy send it
|
|
226
|
+
- **Allowed hosts** - To defend against DNS rebinding, the server rejects (`403`) any request whose `Host` header is missing or not one it answers to: the loopback names (`127.0.0.1`, `::1`, `localhost`) plus the configured bind and link host. If you reach the server under another name - a wildcard bind accessed by LAN IP, a reverse-proxy hostname, or an extra interface - list those names in `LAVISH_AXI_ALLOWED_HOSTS` (whitespace-separated) to allow them. Behind a reverse proxy, the forwarded `X-Forwarded-Host` is validated against the same list, so add your public hostname there and have the proxy send it together with `X-Forwarded-Proto`. Set `LAVISH_AXI_ALLOWED_HOSTS` to `*` to disable the check entirely (only when the server sits behind your own authentication or proxy).
|
|
221
227
|
- **Browser opening** - Set `LAVISH_AXI_NO_OPEN=1`, equivalent to `--no-open`, to create or resume a session without launching a browser window.
|
|
222
228
|
|
|
223
229
|
## CLI Reference
|
package/dist/chrome-client.js
CHANGED
|
@@ -11,6 +11,59 @@ const warningSelectionStorageKey = "lavish-axi:warning-selection:" + key;
|
|
|
11
11
|
const internalQueueKeyField = "_lavishQueueKey";
|
|
12
12
|
const initialChat = Array.isArray(sessionData.initialChat) ? sessionData.initialChat : [];
|
|
13
13
|
const MODE_TOGGLE_HOTKEY_KEY = String(sessionData.modeToggleHotkeyKey || "").toLowerCase();
|
|
14
|
+
const attachmentMaxBytes = Number(sessionData.attachmentMaxBytes) || 0;
|
|
15
|
+
|
|
16
|
+
// The chrome is the only path from the sandboxed (opaque-origin) artifact iframe to
|
|
17
|
+
// the loopback server, so it is the sole place a same-origin confused-deputy can be
|
|
18
|
+
// mediated: the frame's postMessage source check proves a message came from the
|
|
19
|
+
// artifact frame but NOT that a user gesture (paste/drop/pick) drove it, so hostile
|
|
20
|
+
// artifact script could otherwise drive unbounded uploads. Bound both the rate and
|
|
21
|
+
// the cumulative bytes per chrome session here; the server keeps a bounded disk
|
|
22
|
+
// quota as the durable backstop.
|
|
23
|
+
const UPLOAD_RATE_WINDOW_MS = 60_000;
|
|
24
|
+
const UPLOAD_RATE_MAX = 30;
|
|
25
|
+
const UPLOAD_SESSION_BYTE_QUOTA = 256 * 1024 * 1024; // 256 MiB per chrome session
|
|
26
|
+
// The rate and cumulative-byte guards bound uploads over time, but not how many run
|
|
27
|
+
// AT ONCE: each accepted message starts fetch() immediately, so a hostile artifact
|
|
28
|
+
// could post ~30 large bodies in one tick and hold hundreds of MiB of structured
|
|
29
|
+
// clones + server body buffers concurrently before the cumulative quota trips (D8).
|
|
30
|
+
// Cap the number in flight; the over-cap ones are refused with a retry hint (like the
|
|
31
|
+
// rate guard) and a freed slot admits the next.
|
|
32
|
+
const UPLOAD_MAX_IN_FLIGHT = 4;
|
|
33
|
+
const uploadTimestamps = [];
|
|
34
|
+
let uploadedBytesTotal = 0;
|
|
35
|
+
let uploadsInFlight = 0;
|
|
36
|
+
|
|
37
|
+
function formatByteLimit(bytes) {
|
|
38
|
+
if (bytes >= 1024 * 1024) return Math.round(bytes / (1024 * 1024)) + " MB";
|
|
39
|
+
if (bytes >= 1024) return Math.round(bytes / 1024) + " KB";
|
|
40
|
+
return bytes + " bytes";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Turn the server's atomic-reject detail (C4) into one human line naming the cap
|
|
44
|
+
// that was hit, so the user knows what to fix. Nothing was delivered - the queue is
|
|
45
|
+
// preserved - so the wording is about correcting, not about a partial send.
|
|
46
|
+
function describeAttachmentRejection(rejected, caps) {
|
|
47
|
+
const reasons = new Set(rejected.map((ref) => ref && ref.reason));
|
|
48
|
+
const parts = [];
|
|
49
|
+
if (reasons.has("prompt-bytes-exceeded") && caps && caps.maxPromptBytes) {
|
|
50
|
+
parts.push("images exceed the " + formatByteLimit(caps.maxPromptBytes) + " per-annotation limit");
|
|
51
|
+
}
|
|
52
|
+
if (reasons.has("too-many") && caps && caps.maxPerPrompt) {
|
|
53
|
+
parts.push("more than " + caps.maxPerPrompt + " images on one annotation");
|
|
54
|
+
}
|
|
55
|
+
if (reasons.has("too-many-in-request")) {
|
|
56
|
+
parts.push("too many images queued at once");
|
|
57
|
+
}
|
|
58
|
+
if (reasons.has("malformed")) {
|
|
59
|
+
parts.push("an image attachment was malformed");
|
|
60
|
+
}
|
|
61
|
+
if (reasons.has("not-found")) {
|
|
62
|
+
parts.push("an image is no longer available");
|
|
63
|
+
}
|
|
64
|
+
const detail = parts.length ? parts.join("; ") : "some attachments could not be delivered";
|
|
65
|
+
return "Not sent — " + detail + ". Remove or fix the image, then send again.";
|
|
66
|
+
}
|
|
14
67
|
|
|
15
68
|
function isModeToggleHotkeyEvent(event) {
|
|
16
69
|
if (event.shiftKey || event.altKey) return false;
|
|
@@ -164,10 +217,49 @@ function saveJsonState(storageKey, value) {
|
|
|
164
217
|
}
|
|
165
218
|
}
|
|
166
219
|
|
|
220
|
+
// A queued prompt is authored by the untrusted artifact iframe, so its attachment
|
|
221
|
+
// refs are validated at the single boundary every prompt crosses before it is
|
|
222
|
+
// persisted or rendered. Anything that is not a well-formed `{id}` object is
|
|
223
|
+
// dropped: the queue is written to sessionStorage BEFORE it renders, so one bad
|
|
224
|
+
// entry would throw out of render(), stay on disk, and throw again on every
|
|
225
|
+
// reload - wedging the tab permanently instead of failing once. The card's own
|
|
226
|
+
// flow only ever produces `{id, name}`, so a malformed entry is fabricated and
|
|
227
|
+
// there is no user image to preserve. The server re-validates independently.
|
|
228
|
+
// Each surviving ref is PROJECTED onto a fresh primitives-only object rather than
|
|
229
|
+
// kept by reference: postMessage delivers a structured clone, which faithfully
|
|
230
|
+
// preserves BigInt values and cycles that `JSON.stringify` then refuses. Passing
|
|
231
|
+
// the artifact's own object through would carry that junk into sessionStorage and
|
|
232
|
+
// the POST body, where the throw makes the queue unsendable - the same wedge as a
|
|
233
|
+
// poisoned entry, just one step later.
|
|
234
|
+
function sanitizeAttachmentRefs(value) {
|
|
235
|
+
if (!Array.isArray(value)) return [];
|
|
236
|
+
const refs = [];
|
|
237
|
+
for (const ref of value) {
|
|
238
|
+
if (!ref || typeof ref !== "object" || Array.isArray(ref)) continue;
|
|
239
|
+
if (typeof ref.id !== "string" || !ref.id) continue;
|
|
240
|
+
const projected = { id: ref.id };
|
|
241
|
+
if (typeof ref.name === "string" && ref.name) projected.name = ref.name;
|
|
242
|
+
refs.push(projected);
|
|
243
|
+
}
|
|
244
|
+
return refs;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function sanitizeQueuedPrompt(prompt) {
|
|
248
|
+
if (!prompt || typeof prompt !== "object") return null;
|
|
249
|
+
if (!("attachments" in prompt)) return prompt;
|
|
250
|
+
const clean = { ...prompt };
|
|
251
|
+
const refs = sanitizeAttachmentRefs(clean.attachments);
|
|
252
|
+
if (refs.length) clean.attachments = refs;
|
|
253
|
+
else delete clean.attachments;
|
|
254
|
+
return clean;
|
|
255
|
+
}
|
|
256
|
+
|
|
167
257
|
function loadQueuedPrompts() {
|
|
168
258
|
try {
|
|
169
259
|
const parsed = JSON.parse(sessionStorage.getItem(queueStorageKey) || "[]");
|
|
170
|
-
|
|
260
|
+
// Also sanitize on restore: a tab poisoned before this guard existed still has
|
|
261
|
+
// the bad prompt on disk and would otherwise stay wedged after an upgrade.
|
|
262
|
+
return Array.isArray(parsed) ? parsed.map(sanitizeQueuedPrompt).filter(Boolean) : [];
|
|
171
263
|
} catch {
|
|
172
264
|
return [];
|
|
173
265
|
}
|
|
@@ -190,8 +282,10 @@ function render() {
|
|
|
190
282
|
.map(
|
|
191
283
|
(prompt, index) =>
|
|
192
284
|
'<div class="pill-wrap"><div class="pill"><span class="pill-preview">' +
|
|
193
|
-
escapeHtml(prompt.prompt) +
|
|
194
|
-
|
|
285
|
+
escapeHtml(prompt.prompt || (attachmentCount(prompt) ? "Image annotation" : "")) +
|
|
286
|
+
"</span>" +
|
|
287
|
+
pillAttachmentsHtml(prompt) +
|
|
288
|
+
'<button class="pill-close" type="button" aria-label="Remove queued prompt" data-index="' +
|
|
195
289
|
index +
|
|
196
290
|
'"><svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true" focusable="false"><path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></div><div class="pill-tooltip">' +
|
|
197
291
|
(prompt.selector
|
|
@@ -219,12 +313,65 @@ function updateSendState() {
|
|
|
219
313
|
if (warningsQueueButton) updateWarningSelectionState();
|
|
220
314
|
}
|
|
221
315
|
|
|
222
|
-
function
|
|
316
|
+
function attachmentCount(prompt) {
|
|
317
|
+
return Array.isArray(prompt.attachments) ? prompt.attachments.length : 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// How many thumbnails the compact pill shows before the rest collapse into a badge.
|
|
321
|
+
const PILL_THUMBNAIL_LIMIT = 4;
|
|
322
|
+
|
|
323
|
+
// Thumbnails for a queued prompt's images, served straight from the same-origin
|
|
324
|
+
// attachment endpoint (the ids are already server-vetted at upload time). The pill
|
|
325
|
+
// has room for only a few, but the per-prompt cap is configurable
|
|
326
|
+
// (LAVISH_AXI_MAX_ATTACHMENTS_PER_PROMPT), so a prompt can legitimately carry more
|
|
327
|
+
// than fit: the remainder collapses into a +N badge rather than being dropped from
|
|
328
|
+
// the preview, which would make the queue look like it lost the extra images (W-A).
|
|
329
|
+
function pillAttachmentsHtml(prompt) {
|
|
330
|
+
const count = attachmentCount(prompt);
|
|
331
|
+
if (!count) return "";
|
|
332
|
+
const hidden = count - PILL_THUMBNAIL_LIMIT;
|
|
333
|
+
return (
|
|
334
|
+
'<span class="pill-attachments">' +
|
|
335
|
+
prompt.attachments
|
|
336
|
+
.slice(0, PILL_THUMBNAIL_LIMIT)
|
|
337
|
+
.map((attachment) => {
|
|
338
|
+
const alt = escapeHtml(attachment.name || "image");
|
|
339
|
+
return (
|
|
340
|
+
'<img class="pill-attachment" src="/api/' +
|
|
341
|
+
encodeURIComponent(key) +
|
|
342
|
+
"/attachments/" +
|
|
343
|
+
encodeURIComponent(attachment.id) +
|
|
344
|
+
'" alt="' +
|
|
345
|
+
alt +
|
|
346
|
+
'" title="' +
|
|
347
|
+
alt +
|
|
348
|
+
'">'
|
|
349
|
+
);
|
|
350
|
+
})
|
|
351
|
+
.join("") +
|
|
352
|
+
(hidden > 0
|
|
353
|
+
? '<span class="pill-attachment-more" title="' +
|
|
354
|
+
hidden +
|
|
355
|
+
" more image" +
|
|
356
|
+
(hidden === 1 ? "" : "s") +
|
|
357
|
+
'">+' +
|
|
358
|
+
hidden +
|
|
359
|
+
"</span>"
|
|
360
|
+
: "") +
|
|
361
|
+
"</span>"
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const DEFAULT_SEND_HINT = "Write a message or annotate an element first.";
|
|
366
|
+
|
|
367
|
+
function showSendHint(message = DEFAULT_SEND_HINT, holdMs = 2600) {
|
|
368
|
+
sendHint.textContent = message;
|
|
223
369
|
sendHint.hidden = false;
|
|
224
370
|
clearTimeout(sendHintTimer);
|
|
225
371
|
sendHintTimer = setTimeout(() => {
|
|
226
372
|
sendHint.hidden = true;
|
|
227
|
-
|
|
373
|
+
sendHint.textContent = DEFAULT_SEND_HINT;
|
|
374
|
+
}, holdMs);
|
|
228
375
|
chatInput.focus();
|
|
229
376
|
}
|
|
230
377
|
|
|
@@ -354,8 +501,9 @@ function promptQueueKey(prompt) {
|
|
|
354
501
|
return prompt && typeof prompt[internalQueueKeyField] === "string" ? prompt[internalQueueKeyField].trim() : "";
|
|
355
502
|
}
|
|
356
503
|
|
|
357
|
-
function enqueuePrompt(
|
|
358
|
-
|
|
504
|
+
function enqueuePrompt(rawPrompt) {
|
|
505
|
+
const prompt = sanitizeQueuedPrompt(rawPrompt);
|
|
506
|
+
if (!prompt) return;
|
|
359
507
|
|
|
360
508
|
const queueKey = promptQueueKey(prompt);
|
|
361
509
|
if (queueKey) {
|
|
@@ -457,6 +605,15 @@ async function submitQueuedOnce() {
|
|
|
457
605
|
endAfterSubmit = false;
|
|
458
606
|
return false;
|
|
459
607
|
}
|
|
608
|
+
// C4: the server persisted nothing (atomic reject) - the queue below is left
|
|
609
|
+
// intact because the splice only runs on success. Surface exactly what failed
|
|
610
|
+
// so the user can fix the offending attachment(s) rather than losing them.
|
|
611
|
+
if (response.status === 400) {
|
|
612
|
+
const detail = await response.json().catch(() => ({}));
|
|
613
|
+
if (Array.isArray(detail.rejected) && detail.rejected.length) {
|
|
614
|
+
showSendHint(describeAttachmentRejection(detail.rejected, detail.caps), 6000);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
460
617
|
throw new Error("failed to submit queued prompts");
|
|
461
618
|
}
|
|
462
619
|
for (const prompt of prompts) {
|
|
@@ -1293,7 +1450,8 @@ function showWhiteboardOverlay(index) {
|
|
|
1293
1450
|
postToFrame({ type: "lavish:suspendWhiteboard", diagramIndex: index });
|
|
1294
1451
|
// A fresh document per open: the frame boots, posts ready, and receives its
|
|
1295
1452
|
// init - no stale editor state can leak between opens.
|
|
1296
|
-
whiteboardFrame.src =
|
|
1453
|
+
whiteboardFrame.src =
|
|
1454
|
+
"/whiteboard-frame?diagramIndex=" + encodeURIComponent(String(index)) + "&key=" + encodeURIComponent(key);
|
|
1297
1455
|
}
|
|
1298
1456
|
|
|
1299
1457
|
function finishWhiteboardClose(index) {
|
|
@@ -1573,10 +1731,30 @@ function handleAuthenticatedWhiteboardMessage(index, message, mode) {
|
|
|
1573
1731
|
if (message.type === "lavish-whiteboard:flushComplete") finishWhiteboardFlush(index, message, mode);
|
|
1574
1732
|
}
|
|
1575
1733
|
|
|
1734
|
+
// Inline whiteboard frames are created by the SDK inside the artifact document,
|
|
1735
|
+
// so a genuine one is always a direct child of the *current* artifact window.
|
|
1736
|
+
// Descent - not the channel token - is what proves the sender is ours: the
|
|
1737
|
+
// frame page is framable by any origin, so a token is not a secret an attacker
|
|
1738
|
+
// cannot obtain. Without this, any window that could postMessage to this chrome
|
|
1739
|
+
// (a page that framed it, or one holding a window.open handle) could open a
|
|
1740
|
+
// channel and queue a fabricated prompt. Mirrors the artifact-message handler's
|
|
1741
|
+
// `event.source !== frame.contentWindow` guard.
|
|
1742
|
+
function isArtifactChildWindow(source) {
|
|
1743
|
+
if (!source) return false;
|
|
1744
|
+
try {
|
|
1745
|
+
// Reading `parent` on a cross-origin WindowProxy is permitted; the frame's
|
|
1746
|
+
// sandbox makes everything else about it opaque.
|
|
1747
|
+
return source.parent === frame.contentWindow;
|
|
1748
|
+
} catch {
|
|
1749
|
+
return false;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1576
1753
|
function handleInlineWhiteboardMessage(event, message) {
|
|
1577
1754
|
if (ended) return;
|
|
1755
|
+
if (!isArtifactChildWindow(event.source)) return;
|
|
1578
1756
|
const index = validWhiteboardIndex(message.diagramIndex);
|
|
1579
|
-
if (index === null
|
|
1757
|
+
if (index === null) return;
|
|
1580
1758
|
if (message.type === "lavish-whiteboard:ready") {
|
|
1581
1759
|
if (inlineWhiteboardChannels.has(index)) return;
|
|
1582
1760
|
const channelId = String(message.channelToken || "");
|
|
@@ -1736,11 +1914,139 @@ window.addEventListener("message", (event) => {
|
|
|
1736
1914
|
messageToken,
|
|
1737
1915
|
).catch(() => {});
|
|
1738
1916
|
}
|
|
1917
|
+
if (msg.type === "lavish:uploadAttachment") uploadAttachment(msg);
|
|
1918
|
+
// There is deliberately no attachment-delete message. See removeAttachment's
|
|
1919
|
+
// removal note below: the iframe cannot be trusted to decide a delete, and the
|
|
1920
|
+
// chrome cannot see every live reference, so reclamation is the sweeper's job.
|
|
1739
1921
|
if (msg.type === "lavish:sendQueuedPrompts") sendQueued();
|
|
1740
1922
|
if (msg.type === "lavish:endSession") endSession();
|
|
1741
1923
|
if (msg.type === "lavish:toggleAnnotationMode") toggleAnnotationMode();
|
|
1742
1924
|
});
|
|
1743
1925
|
|
|
1926
|
+
// The sandboxed artifact iframe can't reach the loopback server (opaque origin),
|
|
1927
|
+
// so it hands captured image bytes here and the chrome performs the same-origin
|
|
1928
|
+
// upload, then reports the server-vetted id back to the card.
|
|
1929
|
+
async function uploadAttachment(message) {
|
|
1930
|
+
const localId = String(message.localId || "");
|
|
1931
|
+
if (!localId) return;
|
|
1932
|
+
// Echoed verbatim on every result so the artifact can tell a reply to ITS upload
|
|
1933
|
+
// from one still in flight for a previous document (E1). The chrome never
|
|
1934
|
+
// interprets it; it only round-trips it.
|
|
1935
|
+
const nonce = message.nonce;
|
|
1936
|
+
const bytes = message.bytes;
|
|
1937
|
+
let size;
|
|
1938
|
+
if (ArrayBuffer.isView(bytes)) {
|
|
1939
|
+
size = bytes.byteLength;
|
|
1940
|
+
} else {
|
|
1941
|
+
try {
|
|
1942
|
+
const byteLengthGetter = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "byteLength")?.get;
|
|
1943
|
+
size = byteLengthGetter ? byteLengthGetter.call(bytes) : NaN;
|
|
1944
|
+
} catch {
|
|
1945
|
+
size = NaN;
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
if (!Number.isFinite(size) || size < 0) {
|
|
1949
|
+
postToFrame({
|
|
1950
|
+
type: "lavish:attachmentResult",
|
|
1951
|
+
nonce,
|
|
1952
|
+
localId,
|
|
1953
|
+
ok: false,
|
|
1954
|
+
error: "invalid upload payload",
|
|
1955
|
+
});
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
// Reject over-cap images before they hit the network: an over-cap upload aborts
|
|
1959
|
+
// mid-stream, and the browser can hang or reset instead of surfacing the 413, so
|
|
1960
|
+
// the chip would never leave "uploading". Catching it here guarantees the card
|
|
1961
|
+
// reaches its error+retry state. The server still enforces the cap authoritatively.
|
|
1962
|
+
if (attachmentMaxBytes > 0 && size > attachmentMaxBytes) {
|
|
1963
|
+
postToFrame({
|
|
1964
|
+
type: "lavish:attachmentResult",
|
|
1965
|
+
nonce,
|
|
1966
|
+
localId,
|
|
1967
|
+
ok: false,
|
|
1968
|
+
error: "Image is larger than the " + formatByteLimit(attachmentMaxBytes) + " limit",
|
|
1969
|
+
});
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
// Confused-deputy guard: rate + cumulative-byte ceiling before touching the network.
|
|
1973
|
+
const now = Date.now();
|
|
1974
|
+
while (uploadTimestamps.length && now - uploadTimestamps[0] > UPLOAD_RATE_WINDOW_MS) uploadTimestamps.shift();
|
|
1975
|
+
if (uploadTimestamps.length >= UPLOAD_RATE_MAX) {
|
|
1976
|
+
postToFrame({
|
|
1977
|
+
type: "lavish:attachmentResult",
|
|
1978
|
+
nonce,
|
|
1979
|
+
localId,
|
|
1980
|
+
ok: false,
|
|
1981
|
+
error: "Too many uploads. Wait a moment and retry.",
|
|
1982
|
+
});
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
if (uploadedBytesTotal + size > UPLOAD_SESSION_BYTE_QUOTA) {
|
|
1986
|
+
postToFrame({
|
|
1987
|
+
type: "lavish:attachmentResult",
|
|
1988
|
+
nonce,
|
|
1989
|
+
localId,
|
|
1990
|
+
ok: false,
|
|
1991
|
+
error: "Upload limit reached for this session (" + formatByteLimit(UPLOAD_SESSION_BYTE_QUOTA) + ").",
|
|
1992
|
+
});
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
// In-flight ceiling: refuse rather than pile another large body onto the network
|
|
1996
|
+
// while the bound is full. The card keeps its retry affordance, and a settled
|
|
1997
|
+
// upload (below) frees a slot for the next.
|
|
1998
|
+
if (uploadsInFlight >= UPLOAD_MAX_IN_FLIGHT) {
|
|
1999
|
+
postToFrame({
|
|
2000
|
+
type: "lavish:attachmentResult",
|
|
2001
|
+
nonce,
|
|
2002
|
+
localId,
|
|
2003
|
+
ok: false,
|
|
2004
|
+
error: "Too many uploads in flight. Wait a moment and retry.",
|
|
2005
|
+
});
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
uploadTimestamps.push(now);
|
|
2009
|
+
uploadedBytesTotal += size;
|
|
2010
|
+
uploadsInFlight += 1;
|
|
2011
|
+
try {
|
|
2012
|
+
const response = await fetch("/api/" + key + "/attachments", {
|
|
2013
|
+
method: "POST",
|
|
2014
|
+
headers: { "content-type": String(message.mime || "application/octet-stream") },
|
|
2015
|
+
body: bytes,
|
|
2016
|
+
});
|
|
2017
|
+
const data = await response.json().catch(() => ({}));
|
|
2018
|
+
if (!response.ok) throw new Error(data.error || "Upload failed");
|
|
2019
|
+
postToFrame({
|
|
2020
|
+
type: "lavish:attachmentResult",
|
|
2021
|
+
nonce,
|
|
2022
|
+
localId,
|
|
2023
|
+
ok: true,
|
|
2024
|
+
id: (data.attachment && data.attachment.id) || "",
|
|
2025
|
+
});
|
|
2026
|
+
} catch (error) {
|
|
2027
|
+
postToFrame({
|
|
2028
|
+
type: "lavish:attachmentResult",
|
|
2029
|
+
nonce,
|
|
2030
|
+
localId,
|
|
2031
|
+
ok: false,
|
|
2032
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2033
|
+
});
|
|
2034
|
+
} finally {
|
|
2035
|
+
uploadsInFlight -= 1;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// There is intentionally no eager attachment delete here. Removing a chip used to
|
|
2040
|
+
// ask the chrome to DELETE the stored file once no queued prompt referenced it,
|
|
2041
|
+
// but that check is not authoritative: attachments are content-addressed, so two
|
|
2042
|
+
// tabs (or two cards) can hold the SAME id, and a chip that is ready but not yet
|
|
2043
|
+
// queued in another tab is invisible from here. The delete was also driven by the
|
|
2044
|
+
// untrusted iframe, making the chrome a confused deputy - a malicious artifact
|
|
2045
|
+
// could destroy bytes a live card still needed, which then failed as `not-found`
|
|
2046
|
+
// on send. Unreferenced files are reclaimed by the server's reference-aware TTL
|
|
2047
|
+
// sweeper and the disk-cap backstop, which see every session's pending prompts
|
|
2048
|
+
// at once. Deleting late is cheap; deleting bytes someone still needs is not.
|
|
2049
|
+
|
|
1744
2050
|
loadFrame();
|
|
1745
2051
|
|
|
1746
2052
|
function toggleAnnotationMode() {
|
package/dist/chrome.css
CHANGED
|
@@ -1058,6 +1058,36 @@ body.lavish {
|
|
|
1058
1058
|
white-space: nowrap;
|
|
1059
1059
|
text-overflow: ellipsis;
|
|
1060
1060
|
}
|
|
1061
|
+
.pill-attachments {
|
|
1062
|
+
display: inline-flex;
|
|
1063
|
+
align-items: center;
|
|
1064
|
+
gap: 4px;
|
|
1065
|
+
flex: 0 0 auto;
|
|
1066
|
+
}
|
|
1067
|
+
.pill-attachment {
|
|
1068
|
+
width: 22px;
|
|
1069
|
+
height: 22px;
|
|
1070
|
+
border-radius: 5px;
|
|
1071
|
+
object-fit: cover;
|
|
1072
|
+
background: var(--border);
|
|
1073
|
+
border: 1px solid var(--border-strong);
|
|
1074
|
+
}
|
|
1075
|
+
.pill-attachment-more {
|
|
1076
|
+
display: inline-flex;
|
|
1077
|
+
align-items: center;
|
|
1078
|
+
justify-content: center;
|
|
1079
|
+
min-width: 22px;
|
|
1080
|
+
height: 22px;
|
|
1081
|
+
padding: 0 4px;
|
|
1082
|
+
border-radius: 5px;
|
|
1083
|
+
background: var(--border);
|
|
1084
|
+
border: 1px solid var(--border-strong);
|
|
1085
|
+
color: var(--fg-muted);
|
|
1086
|
+
font-size: 10px;
|
|
1087
|
+
font-weight: 700;
|
|
1088
|
+
font-variant-numeric: tabular-nums;
|
|
1089
|
+
line-height: 1;
|
|
1090
|
+
}
|
|
1061
1091
|
.pill-close {
|
|
1062
1092
|
display: flex;
|
|
1063
1093
|
align-items: center;
|